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 @@ ...@@ -2,9 +2,9 @@
<div> <div>
<!-- Loading state --> <!-- Loading state -->
<div v-if="loading" class="space-y-0.5"> <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-12 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="h-3 w-16 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div> <div class="h-3 w-16 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div> <div class="h-3 w-10 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
</div> </div>
<!-- Error state --> <!-- Error state -->
...@@ -17,24 +17,28 @@ ...@@ -17,24 +17,28 @@
<!-- Requests --> <!-- Requests -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400">Req:</span> <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> </div>
<!-- Tokens --> <!-- Tokens -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400">Tok:</span> <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> </div>
<!-- Cost --> <!-- Cost -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400">Cost:</span> <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>
</div> </div>
<!-- No data --> <!-- No data -->
<div v-else class="text-xs text-gray-400"> <div v-else class="text-xs text-gray-400">-</div>
-
</div>
</div> </div>
</template> </template>
......
<template> <template>
<div v-if="showUsageWindows"> <div v-if="showUsageWindows">
<!-- Anthropic OAuth and Setup Token accounts: fetch real usage data --> <!-- 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 --> <!-- Loading state -->
<div v-if="loading" class="space-y-1.5"> <div v-if="loading" class="space-y-1.5">
<!-- OAuth: 3 rows, Setup Token: 1 row --> <!-- OAuth: 3 rows, Setup Token: 1 row -->
<div class="flex items-center gap-1"> <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="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse"></div> <div class="h-1.5 w-8 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700"></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> </div>
<template v-if="account.type === 'oauth'"> <template v-if="account.type === 'oauth'">
<div class="flex items-center gap-1"> <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="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse"></div> <div class="h-1.5 w-8 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700"></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> </div>
<div class="flex items-center gap-1"> <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="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse"></div> <div class="h-1.5 w-8 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700"></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> </div>
</template> </template>
</div> </div>
...@@ -61,9 +66,7 @@ ...@@ -61,9 +66,7 @@
</div> </div>
<!-- No data yet --> <!-- No data yet -->
<div v-else class="text-xs text-gray-400"> <div v-else class="text-xs text-gray-400">-</div>
-
</div>
</template> </template>
<!-- OpenAI OAuth accounts: show Codex usage from extra field --> <!-- OpenAI OAuth accounts: show Codex usage from extra field -->
...@@ -97,9 +100,7 @@ ...@@ -97,9 +100,7 @@
</div> </div>
<!-- Non-OAuth/Setup-Token accounts --> <!-- Non-OAuth/Setup-Token accounts -->
<div v-else class="text-xs text-gray-400"> <div v-else class="text-xs text-gray-400">-</div>
-
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
...@@ -117,20 +118,21 @@ const error = ref<string | null>(null) ...@@ -117,20 +118,21 @@ const error = ref<string | null>(null)
const usageInfo = ref<AccountUsageInfo | null>(null) const usageInfo = ref<AccountUsageInfo | null>(null)
// Show usage windows for OAuth and Setup Token accounts // Show usage windows for OAuth and Setup Token accounts
const showUsageWindows = computed(() => const showUsageWindows = computed(
props.account.type === 'oauth' || props.account.type === 'setup-token' () => props.account.type === 'oauth' || props.account.type === 'setup-token'
) )
// OpenAI Codex usage computed properties // OpenAI Codex usage computed properties
const hasCodexUsage = computed(() => { const hasCodexUsage = computed(() => {
const extra = props.account.extra const extra = props.account.extra
return extra && ( return (
extra &&
// Check for new canonical fields first // Check for new canonical fields first
extra.codex_5h_used_percent !== undefined || (extra.codex_5h_used_percent !== undefined ||
extra.codex_7d_used_percent !== undefined || extra.codex_7d_used_percent !== undefined ||
// Fallback to legacy fields // Fallback to legacy fields
extra.codex_primary_used_percent !== undefined || 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(() => { ...@@ -145,10 +147,16 @@ const codex5hUsedPercent = computed(() => {
} }
// Fallback: detect from legacy fields using window_minutes // 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 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 return extra.codex_secondary_used_percent ?? null
} }
...@@ -167,13 +175,19 @@ const codex5hResetAt = computed(() => { ...@@ -167,13 +175,19 @@ const codex5hResetAt = computed(() => {
} }
// Fallback: detect from legacy fields using window_minutes // 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) { if (extra.codex_primary_reset_after_seconds !== undefined) {
const resetTime = new Date(Date.now() + extra.codex_primary_reset_after_seconds * 1000) const resetTime = new Date(Date.now() + extra.codex_primary_reset_after_seconds * 1000)
return resetTime.toISOString() 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) { if (extra.codex_secondary_reset_after_seconds !== undefined) {
const resetTime = new Date(Date.now() + extra.codex_secondary_reset_after_seconds * 1000) const resetTime = new Date(Date.now() + extra.codex_secondary_reset_after_seconds * 1000)
return resetTime.toISOString() return resetTime.toISOString()
...@@ -200,10 +214,16 @@ const codex7dUsedPercent = computed(() => { ...@@ -200,10 +214,16 @@ const codex7dUsedPercent = computed(() => {
} }
// Fallback: detect from legacy fields using window_minutes // 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 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 return extra.codex_secondary_used_percent ?? null
} }
...@@ -222,13 +242,19 @@ const codex7dResetAt = computed(() => { ...@@ -222,13 +242,19 @@ const codex7dResetAt = computed(() => {
} }
// Fallback: detect from legacy fields using window_minutes // 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) { if (extra.codex_primary_reset_after_seconds !== undefined) {
const resetTime = new Date(Date.now() + extra.codex_primary_reset_after_seconds * 1000) const resetTime = new Date(Date.now() + extra.codex_primary_reset_after_seconds * 1000)
return resetTime.toISOString() 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) { if (extra.codex_secondary_reset_after_seconds !== undefined) {
const resetTime = new Date(Date.now() + extra.codex_secondary_reset_after_seconds * 1000) const resetTime = new Date(Date.now() + extra.codex_secondary_reset_after_seconds * 1000)
return resetTime.toISOString() return resetTime.toISOString()
......
...@@ -2,9 +2,9 @@ ...@@ -2,9 +2,9 @@
<Modal :show="show" :title="t('admin.accounts.bulkEdit.title')" size="lg" @close="handleClose"> <Modal :show="show" :title="t('admin.accounts.bulkEdit.title')" size="lg" @close="handleClose">
<form class="space-y-5" @submit.prevent="handleSubmit"> <form class="space-y-5" @submit.prevent="handleSubmit">
<!-- Info --> <!-- 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"> <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 <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
...@@ -17,8 +17,8 @@ ...@@ -17,8 +17,8 @@
</div> </div>
<!-- Base URL (API Key only) --> <!-- Base URL (API Key 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 mb-3"> <div class="mb-3 flex items-center justify-between">
<label class="input-label mb-0">{{ t('admin.accounts.baseUrl') }}</label> <label class="input-label mb-0">{{ t('admin.accounts.baseUrl') }}</label>
<input <input
v-model="enableBaseUrl" v-model="enableBaseUrl"
...@@ -31,7 +31,7 @@ ...@@ -31,7 +31,7 @@
type="text" type="text"
:disabled="!enableBaseUrl" :disabled="!enableBaseUrl"
class="input" class="input"
:class="!enableBaseUrl && 'opacity-50 cursor-not-allowed'" :class="!enableBaseUrl && 'cursor-not-allowed opacity-50'"
:placeholder="t('admin.accounts.bulkEdit.baseUrlPlaceholder')" :placeholder="t('admin.accounts.bulkEdit.baseUrlPlaceholder')"
/> />
<p class="input-hint"> <p class="input-hint">
...@@ -40,8 +40,8 @@ ...@@ -40,8 +40,8 @@
</div> </div>
<!-- Model restriction --> <!-- Model restriction -->
<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 mb-3"> <div class="mb-3 flex items-center justify-between">
<label class="input-label mb-0">{{ t('admin.accounts.modelRestriction') }}</label> <label class="input-label mb-0">{{ t('admin.accounts.modelRestriction') }}</label>
<input <input
v-model="enableModelRestriction" v-model="enableModelRestriction"
...@@ -50,21 +50,21 @@ ...@@ -50,21 +50,21 @@
/> />
</div> </div>
<div :class="!enableModelRestriction && 'opacity-50 pointer-events-none'"> <div :class="!enableModelRestriction && 'pointer-events-none opacity-50'">
<!-- Mode Toggle --> <!-- Mode Toggle -->
<div class="flex gap-2 mb-4"> <div class="mb-4 flex gap-2">
<button <button
type="button" type="button"
:class="[ :class="[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all', 'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
modelRestrictionMode === 'whitelist' modelRestrictionMode === 'whitelist'
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400' ? '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'" @click="modelRestrictionMode = 'whitelist'"
> >
<svg <svg
class="w-4 h-4 inline mr-1.5" class="mr-1.5 inline h-4 w-4"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
...@@ -84,12 +84,12 @@ ...@@ -84,12 +84,12 @@
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all', 'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
modelRestrictionMode === 'mapping' modelRestrictionMode === 'mapping'
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' ? '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'" @click="modelRestrictionMode = 'mapping'"
> >
<svg <svg
class="w-4 h-4 inline mr-1.5" class="mr-1.5 inline h-4 w-4"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
...@@ -107,10 +107,10 @@ ...@@ -107,10 +107,10 @@
<!-- Whitelist Mode --> <!-- Whitelist Mode -->
<div v-if="modelRestrictionMode === 'whitelist'"> <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"> <p class="text-xs text-blue-700 dark:text-blue-400">
<svg <svg
class="w-4 h-4 inline mr-1" class="mr-1 inline h-4 w-4"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
...@@ -127,7 +127,7 @@ ...@@ -127,7 +127,7 @@
</div> </div>
<!-- Model Checkbox List --> <!-- Model Checkbox List -->
<div class="grid grid-cols-2 gap-2 mb-3"> <div class="mb-3 grid grid-cols-2 gap-2">
<label <label
v-for="model in allModels" v-for="model in allModels"
:key="model.value" :key="model.value"
...@@ -158,10 +158,10 @@ ...@@ -158,10 +158,10 @@
<!-- Mapping Mode --> <!-- Mapping Mode -->
<div v-else> <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"> <p class="text-xs text-purple-700 dark:text-purple-400">
<svg <svg
class="w-4 h-4 inline mr-1" class="mr-1 inline h-4 w-4"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
...@@ -178,7 +178,7 @@ ...@@ -178,7 +178,7 @@
</div> </div>
<!-- Model Mapping List --> <!-- 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 <div
v-for="(mapping, index) in modelMappings" v-for="(mapping, index) in modelMappings"
:key="index" :key="index"
...@@ -191,7 +191,7 @@ ...@@ -191,7 +191,7 @@
:placeholder="t('admin.accounts.requestModel')" :placeholder="t('admin.accounts.requestModel')"
/> />
<svg <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" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
...@@ -211,10 +211,10 @@ ...@@ -211,10 +211,10 @@
/> />
<button <button
type="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)" @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 <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
...@@ -228,11 +228,11 @@ ...@@ -228,11 +228,11 @@
<button <button
type="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" @click="addModelMapping"
> >
<svg <svg
class="w-4 h-4 inline mr-1" class="mr-1 inline h-4 w-4"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
...@@ -264,11 +264,11 @@ ...@@ -264,11 +264,11 @@
</div> </div>
<!-- Custom error codes --> <!-- Custom error codes -->
<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 mb-3"> <div class="mb-3 flex items-center justify-between">
<div> <div>
<label class="input-label mb-0">{{ t('admin.accounts.customErrorCodes') }}</label> <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') }} {{ t('admin.accounts.customErrorCodesHint') }}
</p> </p>
</div> </div>
...@@ -280,10 +280,10 @@ ...@@ -280,10 +280,10 @@
</div> </div>
<div v-if="enableCustomErrorCodes" class="space-y-3"> <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"> <p class="text-xs text-amber-700 dark:text-amber-400">
<svg <svg
class="w-4 h-4 inline mr-1" class="mr-1 inline h-4 w-4"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
...@@ -308,8 +308,8 @@ ...@@ -308,8 +308,8 @@
:class="[ :class="[
'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors', 'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
selectedErrorCodes.includes(code.value) 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', : '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)" @click="toggleErrorCode(code.value)"
> >
...@@ -329,7 +329,7 @@ ...@@ -329,7 +329,7 @@
@keyup.enter="addCustomErrorCode" @keyup.enter="addCustomErrorCode"
/> />
<button type="button" class="btn btn-secondary px-3" @click="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 <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
...@@ -345,7 +345,7 @@ ...@@ -345,7 +345,7 @@
<span <span
v-for="code in selectedErrorCodes.sort((a, b) => a - b)" v-for="code in selectedErrorCodes.sort((a, b) => a - b)"
:key="code" :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 }} {{ code }}
<button <button
...@@ -353,7 +353,7 @@ ...@@ -353,7 +353,7 @@
class="hover:text-red-900 dark:hover:text-red-300" class="hover:text-red-900 dark:hover:text-red-300"
@click="removeErrorCode(code)" @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 <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
...@@ -371,13 +371,13 @@ ...@@ -371,13 +371,13 @@
</div> </div>
<!-- Intercept warmup requests (Anthropic only) --> <!-- 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 items-center justify-between">
<div class="flex-1 pr-4"> <div class="flex-1 pr-4">
<label class="input-label mb-0">{{ <label class="input-label mb-0">{{
t('admin.accounts.interceptWarmupRequests') t('admin.accounts.interceptWarmupRequests')
}}</label> }}</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') }} {{ t('admin.accounts.interceptWarmupRequestsDesc') }}
</p> </p>
</div> </div>
...@@ -392,14 +392,14 @@ ...@@ -392,14 +392,14 @@
type="button" type="button"
:class="[ :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', '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" @click="interceptWarmupRequests = !interceptWarmupRequests"
> >
<span <span
:class="[ :class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out', '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> </button>
...@@ -407,8 +407,8 @@ ...@@ -407,8 +407,8 @@
</div> </div>
<!-- Proxy --> <!-- Proxy -->
<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 mb-3"> <div class="mb-3 flex items-center justify-between">
<label class="input-label mb-0">{{ t('admin.accounts.proxy') }}</label> <label class="input-label mb-0">{{ t('admin.accounts.proxy') }}</label>
<input <input
v-model="enableProxy" v-model="enableProxy"
...@@ -416,15 +416,15 @@ ...@@ -416,15 +416,15 @@
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/> />
</div> </div>
<div :class="!enableProxy && 'opacity-50 pointer-events-none'"> <div :class="!enableProxy && 'pointer-events-none opacity-50'">
<ProxySelector v-model="proxyId" :proxies="proxies" /> <ProxySelector v-model="proxyId" :proxies="proxies" />
</div> </div>
</div> </div>
<!-- Concurrency & Priority --> <!-- 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>
<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> <label class="input-label mb-0">{{ t('admin.accounts.concurrency') }}</label>
<input <input
v-model="enableConcurrency" v-model="enableConcurrency"
...@@ -438,11 +438,11 @@ ...@@ -438,11 +438,11 @@
min="1" min="1"
:disabled="!enableConcurrency" :disabled="!enableConcurrency"
class="input" class="input"
:class="!enableConcurrency && 'opacity-50 cursor-not-allowed'" :class="!enableConcurrency && 'cursor-not-allowed opacity-50'"
/> />
</div> </div>
<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> <label class="input-label mb-0">{{ t('admin.accounts.priority') }}</label>
<input <input
v-model="enablePriority" v-model="enablePriority"
...@@ -456,14 +456,14 @@ ...@@ -456,14 +456,14 @@
min="1" min="1"
:disabled="!enablePriority" :disabled="!enablePriority"
class="input" class="input"
:class="!enablePriority && 'opacity-50 cursor-not-allowed'" :class="!enablePriority && 'cursor-not-allowed opacity-50'"
/> />
</div> </div>
</div> </div>
<!-- Status --> <!-- Status -->
<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 mb-3"> <div class="mb-3 flex items-center justify-between">
<label class="input-label mb-0">{{ t('common.status') }}</label> <label class="input-label mb-0">{{ t('common.status') }}</label>
<input <input
v-model="enableStatus" v-model="enableStatus"
...@@ -471,14 +471,14 @@ ...@@ -471,14 +471,14 @@
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/> />
</div> </div>
<div :class="!enableStatus && 'opacity-50 pointer-events-none'"> <div :class="!enableStatus && 'pointer-events-none opacity-50'">
<Select v-model="status" :options="statusOptions" /> <Select v-model="status" :options="statusOptions" />
</div> </div>
</div> </div>
<!-- Groups --> <!-- Groups -->
<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 mb-3"> <div class="mb-3 flex items-center justify-between">
<label class="input-label mb-0">{{ t('nav.groups') }}</label> <label class="input-label mb-0">{{ t('nav.groups') }}</label>
<input <input
v-model="enableGroups" v-model="enableGroups"
...@@ -486,7 +486,7 @@ ...@@ -486,7 +486,7 @@
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/> />
</div> </div>
<div :class="!enableGroups && 'opacity-50 pointer-events-none'"> <div :class="!enableGroups && 'pointer-events-none opacity-50'">
<GroupSelector v-model="groupIds" :groups="groups" /> <GroupSelector v-model="groupIds" :groups="groups" />
</div> </div>
</div> </div>
...@@ -499,7 +499,7 @@ ...@@ -499,7 +499,7 @@
<button type="submit" :disabled="submitting" class="btn btn-primary"> <button type="submit" :disabled="submitting" class="btn btn-primary">
<svg <svg
v-if="submitting" 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" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
...@@ -601,7 +601,7 @@ const allModels = [ ...@@ -601,7 +601,7 @@ const allModels = [
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' }, { 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-2025-11-13', label: 'GPT-5.1' },
{ value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' }, { 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) // Preset mappings (combined Anthropic + OpenAI)
...@@ -610,48 +610,46 @@ const presetMappings = [ ...@@ -610,48 +610,46 @@ const presetMappings = [
label: 'Sonnet 4', label: 'Sonnet 4',
from: 'claude-sonnet-4-20250514', from: 'claude-sonnet-4-20250514',
to: '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', label: 'Sonnet 4.5',
from: 'claude-sonnet-4-5-20250929', from: 'claude-sonnet-4-5-20250929',
to: 'claude-sonnet-4-5-20250929', to: 'claude-sonnet-4-5-20250929',
color: 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', label: 'Opus 4.5',
from: 'claude-opus-4-5-20251101', from: 'claude-opus-4-5-20251101',
to: 'claude-opus-4-5-20251101', to: 'claude-opus-4-5-20251101',
color: 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', label: 'Opus->Sonnet',
from: 'claude-opus-4-5-20251101', from: 'claude-opus-4-5-20251101',
to: 'claude-sonnet-4-5-20250929', to: 'claude-sonnet-4-5-20250929',
color: color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400'
'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400',
}, },
{ {
label: 'GPT-5.2', label: 'GPT-5.2',
from: 'gpt-5.2-2025-12-11', from: 'gpt-5.2-2025-12-11',
to: 'gpt-5.2-2025-12-11', to: 'gpt-5.2-2025-12-11',
color: color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400'
'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400',
}, },
{ {
label: 'GPT-5.2 Codex', label: 'GPT-5.2 Codex',
from: 'gpt-5.2-codex', from: 'gpt-5.2-codex',
to: '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', label: 'Max->Codex',
from: 'gpt-5.1-codex-max', from: 'gpt-5.1-codex-max',
to: 'gpt-5.1-codex', 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 // Common HTTP error codes
...@@ -662,12 +660,12 @@ const commonErrorCodes = [ ...@@ -662,12 +660,12 @@ const commonErrorCodes = [
{ value: 500, label: 'Server Error' }, { value: 500, label: 'Server Error' },
{ value: 502, label: 'Bad Gateway' }, { value: 502, label: 'Bad Gateway' },
{ value: 503, label: 'Unavailable' }, { value: 503, label: 'Unavailable' },
{ value: 529, label: 'Overloaded' }, { value: 529, label: 'Overloaded' }
] ]
const statusOptions = computed(() => [ const statusOptions = computed(() => [
{ value: 'active', label: t('common.active') }, { value: 'active', label: t('common.active') },
{ value: 'inactive', label: t('common.inactive') }, { value: 'inactive', label: t('common.inactive') }
]) ])
// Model mapping helpers // Model mapping helpers
......
<template> <template>
<Modal <Modal :show="show" :title="t('admin.accounts.createAccount')" size="lg" @close="handleClose">
:show="show"
:title="t('admin.accounts.createAccount')"
size="lg"
@close="handleClose"
>
<!-- Step Indicator for OAuth accounts --> <!-- Step Indicator for OAuth accounts -->
<div v-if="isOAuthFlow" class="mb-6 flex items-center justify-center"> <div v-if="isOAuthFlow" class="mb-6 flex items-center justify-center">
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
...@@ -17,7 +12,9 @@ ...@@ -17,7 +12,9 @@
> >
1 1
</div> </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>
<div class="h-0.5 w-8 bg-gray-300 dark:bg-dark-600" /> <div class="h-0.5 w-8 bg-gray-300 dark:bg-dark-600" />
<div class="flex items-center"> <div class="flex items-center">
...@@ -29,7 +26,9 @@ ...@@ -29,7 +26,9 @@
> >
2 2
</div> </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> </div>
</div> </div>
...@@ -50,19 +49,29 @@ ...@@ -50,19 +49,29 @@
<!-- Platform Selection - Segmented Control Style --> <!-- Platform Selection - Segmented Control Style -->
<div> <div>
<label class="input-label">{{ t('admin.accounts.platform') }}</label> <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 <button
type="button" type="button"
@click="form.platform = 'anthropic'" @click="form.platform = 'anthropic'"
:class="[ :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' form.platform === 'anthropic'
? 'bg-white dark:bg-dark-600 text-orange-600 dark:text-orange-400 shadow-sm' ? 'bg-white text-orange-600 shadow-sm dark:bg-dark-600 dark:text-orange-400'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200' : '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"> <svg
<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" /> 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> </svg>
Anthropic Anthropic
</button> </button>
...@@ -70,47 +79,98 @@ ...@@ -70,47 +79,98 @@
type="button" type="button"
@click="form.platform = 'openai'" @click="form.platform = 'openai'"
:class="[ :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' form.platform === 'openai'
? 'bg-white dark:bg-dark-600 text-green-600 dark:text-green-400 shadow-sm' ? 'bg-white text-green-600 shadow-sm dark:bg-dark-600 dark:text-green-400'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200' : '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"> <svg
<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" /> 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> </svg>
OpenAI OpenAI
</button> </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>
</div> </div>
<!-- Account Type Selection (Anthropic) --> <!-- Account Type Selection (Anthropic) -->
<div v-if="form.platform === 'anthropic'"> <div v-if="form.platform === 'anthropic'">
<label class="input-label">{{ t('admin.accounts.accountType') }}</label> <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 <button
type="button" type="button"
@click="accountCategory = 'oauth-based'" @click="accountCategory = 'oauth-based'"
:class="[ :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' accountCategory === 'oauth-based'
? 'border-orange-500 bg-orange-50 dark:bg-orange-900/20' ? '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
'flex h-8 w-8 items-center justify-center rounded-lg', :class="[
accountCategory === 'oauth-based' 'flex h-8 w-8 items-center justify-center rounded-lg',
? 'bg-orange-500 text-white' accountCategory === 'oauth-based'
: 'bg-gray-100 dark:bg-dark-600 text-gray-500 dark:text-gray-400' ? 'bg-orange-500 text-white'
]"> : 'bg-gray-100 text-gray-500 dark:bg-dark-600 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" /> >
<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> </svg>
</div> </div>
<div> <div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.accounts.claudeCode') }}</span> <span class="block text-sm font-medium text-gray-900 dark:text-white">{{
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.oauthSetupToken') }}</span> t('admin.accounts.claudeCode')
}}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.oauthSetupToken')
}}</span>
</div> </div>
</button> </button>
...@@ -118,25 +178,41 @@ ...@@ -118,25 +178,41 @@
type="button" type="button"
@click="accountCategory = 'apikey'" @click="accountCategory = 'apikey'"
:class="[ :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' accountCategory === 'apikey'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' ? '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
'flex h-8 w-8 items-center justify-center rounded-lg', :class="[
accountCategory === 'apikey' 'flex h-8 w-8 items-center justify-center rounded-lg',
? 'bg-purple-500 text-white' accountCategory === 'apikey'
: 'bg-gray-100 dark:bg-dark-600 text-gray-500 dark:text-gray-400' ? 'bg-purple-500 text-white'
]"> : 'bg-gray-100 text-gray-500 dark:bg-dark-600 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" /> >
<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> </svg>
</div> </div>
<div> <div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.accounts.claudeConsole') }}</span> <span class="block text-sm font-medium text-gray-900 dark:text-white">{{
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.apiKey') }}</span> t('admin.accounts.claudeConsole')
}}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.apiKey')
}}</span>
</div> </div>
</button> </button>
</div> </div>
...@@ -145,25 +221,37 @@ ...@@ -145,25 +221,37 @@
<!-- Account Type Selection (OpenAI) --> <!-- Account Type Selection (OpenAI) -->
<div v-if="form.platform === 'openai'"> <div v-if="form.platform === 'openai'">
<label class="input-label">{{ t('admin.accounts.accountType') }}</label> <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 <button
type="button" type="button"
@click="accountCategory = 'oauth-based'" @click="accountCategory = 'oauth-based'"
:class="[ :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' accountCategory === 'oauth-based'
? 'border-green-500 bg-green-50 dark:bg-green-900/20' ? '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
'flex h-8 w-8 items-center justify-center rounded-lg', :class="[
accountCategory === 'oauth-based' 'flex h-8 w-8 items-center justify-center rounded-lg',
? 'bg-green-500 text-white' accountCategory === 'oauth-based'
: 'bg-gray-100 dark:bg-dark-600 text-gray-500 dark:text-gray-400' ? 'bg-green-500 text-white'
]"> : 'bg-gray-100 text-gray-500 dark:bg-dark-600 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" /> >
<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> </svg>
</div> </div>
<div> <div>
...@@ -176,20 +264,32 @@ ...@@ -176,20 +264,32 @@
type="button" type="button"
@click="accountCategory = 'apikey'" @click="accountCategory = 'apikey'"
:class="[ :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' accountCategory === 'apikey'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' ? '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
'flex h-8 w-8 items-center justify-center rounded-lg', :class="[
accountCategory === 'apikey' 'flex h-8 w-8 items-center justify-center rounded-lg',
? 'bg-purple-500 text-white' accountCategory === 'apikey'
: 'bg-gray-100 dark:bg-dark-600 text-gray-500 dark:text-gray-400' ? 'bg-purple-500 text-white'
]"> : 'bg-gray-100 text-gray-500 dark:bg-dark-600 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" /> >
<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> </svg>
</div> </div>
<div> <div>
...@@ -200,10 +300,187 @@ ...@@ -200,10 +300,187 @@
</div> </div>
</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) --> <!-- Add Method (only for Anthropic OAuth-based type) -->
<div v-if="form.platform === 'anthropic' && isOAuthFlow"> <div v-if="form.platform === 'anthropic' && isOAuthFlow">
<label class="input-label">{{ t('admin.accounts.addMethod') }}</label> <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"> <label class="flex cursor-pointer items-center">
<input <input
v-model="addMethod" v-model="addMethod"
...@@ -220,7 +497,9 @@ ...@@ -220,7 +497,9 @@
value="setup-token" value="setup-token"
class="mr-2 text-primary-600 focus:ring-primary-500" 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> </label>
</div> </div>
</div> </div>
...@@ -233,7 +512,13 @@ ...@@ -233,7 +512,13 @@
v-model="apiKeyBaseUrl" v-model="apiKeyBaseUrl"
type="text" type="text"
class="input" 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> <p class="input-hint">{{ t('admin.accounts.baseUrlHint') }}</p>
</div> </div>
...@@ -244,17 +529,29 @@ ...@@ -244,17 +529,29 @@
type="password" type="password"
required required
class="input font-mono" 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> </div>
<!-- Model Restriction Section --> <!-- Model Restriction Section (不适用于 Gemini) -->
<div class="border-t border-gray-200 dark:border-dark-600 pt-4"> <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> <label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
<!-- Mode Toggle --> <!-- Mode Toggle -->
<div class="flex gap-2 mb-4"> <div class="mb-4 flex gap-2">
<button <button
type="button" type="button"
@click="modelRestrictionMode = 'whitelist'" @click="modelRestrictionMode = 'whitelist'"
...@@ -265,8 +562,18 @@ ...@@ -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' : '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"> <svg
<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" /> 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> </svg>
{{ t('admin.accounts.modelWhitelist') }} {{ t('admin.accounts.modelWhitelist') }}
</button> </button>
...@@ -280,8 +587,18 @@ ...@@ -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' : '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"> <svg
<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" /> 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> </svg>
{{ t('admin.accounts.modelMapping') }} {{ t('admin.accounts.modelMapping') }}
</button> </button>
...@@ -289,22 +606,36 @@ ...@@ -289,22 +606,36 @@
<!-- Whitelist Mode --> <!-- Whitelist Mode -->
<div v-if="modelRestrictionMode === 'whitelist'"> <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"> <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"> <svg
<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" /> 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> </svg>
{{ t('admin.accounts.selectAllowedModels') }} {{ t('admin.accounts.selectAllowedModels') }}
</p> </p>
</div> </div>
<!-- Model Checkbox List --> <!-- Model Checkbox List -->
<div class="grid grid-cols-2 gap-2 mb-3"> <div class="mb-3 grid grid-cols-2 gap-2">
<label <label
v-for="model in commonModels" v-for="model in commonModels"
:key="model.value" :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="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 <input
type="checkbox" type="checkbox"
...@@ -318,23 +649,35 @@ ...@@ -318,23 +649,35 @@
<p class="text-xs text-gray-500 dark:text-gray-400"> <p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }} {{ 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> </p>
</div> </div>
<!-- Mapping Mode --> <!-- Mapping Mode -->
<div v-else> <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"> <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"> <svg
<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" /> 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> </svg>
{{ t('admin.accounts.mapRequestModels') }} {{ t('admin.accounts.mapRequestModels') }}
</p> </p>
</div> </div>
<!-- Model Mapping List --> <!-- 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 <div
v-for="(mapping, index) in modelMappings" v-for="(mapping, index) in modelMappings"
:key="index" :key="index"
...@@ -346,8 +689,18 @@ ...@@ -346,8 +689,18 @@
class="input flex-1" class="input flex-1"
:placeholder="t('admin.accounts.requestModel')" :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"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" /> 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> </svg>
<input <input
v-model="mapping.to" v-model="mapping.to"
...@@ -358,10 +711,15 @@ ...@@ -358,10 +711,15 @@
<button <button
type="button" type="button"
@click="removeModelMapping(index)" @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"> <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" /> <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> </svg>
</button> </button>
</div> </div>
...@@ -370,10 +728,20 @@ ...@@ -370,10 +728,20 @@
<button <button
type="button" type="button"
@click="addModelMapping" @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="w-4 h-4 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /> 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 4v16m8-8H4"
/>
</svg> </svg>
{{ t('admin.accounts.addMapping') }} {{ t('admin.accounts.addMapping') }}
</button> </button>
...@@ -385,10 +753,7 @@ ...@@ -385,10 +753,7 @@
:key="preset.label" :key="preset.label"
type="button" type="button"
@click="addPresetMapping(preset.from, preset.to)" @click="addPresetMapping(preset.from, preset.to)"
:class="[ :class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
'rounded-lg px-3 py-1 text-xs transition-colors',
preset.color
]"
> >
+ {{ preset.label }} + {{ preset.label }}
</button> </button>
...@@ -397,11 +762,16 @@ ...@@ -397,11 +762,16 @@
</div> </div>
<!-- Custom Error Codes Section --> <!-- Custom Error Codes Section -->
<div class="border-t border-gray-200 dark:border-dark-600 pt-4"> <div
<div class="flex items-center justify-between mb-3"> 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> <div>
<label class="input-label mb-0">{{ t('admin.accounts.customErrorCodes') }}</label> <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> </div>
<button <button
type="button" type="button"
...@@ -421,10 +791,20 @@ ...@@ -421,10 +791,20 @@
</div> </div>
<div v-if="customErrorCodesEnabled" class="space-y-3"> <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"> <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"> <svg
<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" /> 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> </svg>
{{ t('admin.accounts.customErrorCodesWarning') }} {{ t('admin.accounts.customErrorCodesWarning') }}
</p> </p>
...@@ -440,7 +820,7 @@ ...@@ -440,7 +820,7 @@
:class="[ :class="[
'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors', 'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
selectedErrorCodes.includes(code.value) 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' : '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 @@ ...@@ -459,13 +839,14 @@
:placeholder="t('admin.accounts.enterErrorCode')" :placeholder="t('admin.accounts.enterErrorCode')"
@keyup.enter="addCustomErrorCode" @keyup.enter="addCustomErrorCode"
/> />
<button <button type="button" @click="addCustomErrorCode" class="btn btn-secondary px-3">
type="button" <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@click="addCustomErrorCode" <path
class="btn btn-secondary px-3" stroke-linecap="round"
> stroke-linejoin="round"
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> stroke-width="2"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /> d="M12 4v16m8-8H4"
/>
</svg> </svg>
</button> </button>
</div> </div>
...@@ -475,7 +856,7 @@ ...@@ -475,7 +856,7 @@
<span <span
v-for="code in selectedErrorCodes.sort((a, b) => a - b)" v-for="code in selectedErrorCodes.sort((a, b) => a - b)"
:key="code" :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 }} {{ code }}
<button <button
...@@ -483,8 +864,13 @@ ...@@ -483,8 +864,13 @@
@click="removeErrorCode(code)" @click="removeErrorCode(code)"
class="hover:text-red-900 dark:hover:text-red-300" 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"> <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" /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg> </svg>
</button> </button>
</span> </span>
...@@ -494,14 +880,50 @@ ...@@ -494,14 +880,50 @@
</div> </div>
</div> </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> </div>
<!-- Intercept Warmup Requests (Anthropic only) --> <!-- 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 class="flex items-center justify-between">
<div> <div>
<label class="input-label mb-0">{{ t('admin.accounts.interceptWarmupRequests') }}</label> <label class="input-label mb-0">{{
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.accounts.interceptWarmupRequestsDesc') }}</p> t('admin.accounts.interceptWarmupRequests')
}}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.interceptWarmupRequestsDesc') }}
</p>
</div> </div>
<button <button
type="button" type="button"
...@@ -523,64 +945,56 @@ ...@@ -523,64 +945,56 @@
<div> <div>
<label class="input-label">{{ t('admin.accounts.proxy') }}</label> <label class="input-label">{{ t('admin.accounts.proxy') }}</label>
<ProxySelector <ProxySelector v-model="form.proxy_id" :proxies="proxies" />
v-model="form.proxy_id"
:proxies="proxies"
/>
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div> <div>
<label class="input-label">{{ t('admin.accounts.concurrency') }}</label> <label class="input-label">{{ t('admin.accounts.concurrency') }}</label>
<input <input v-model.number="form.concurrency" type="number" min="1" class="input" />
v-model.number="form.concurrency"
type="number"
min="1"
class="input"
/>
</div> </div>
<div> <div>
<label class="input-label">{{ t('admin.accounts.priority') }}</label> <label class="input-label">{{ t('admin.accounts.priority') }}</label>
<input <input v-model.number="form.priority" type="number" min="1" class="input" />
v-model.number="form.priority"
type="number"
min="1"
class="input"
/>
<p class="input-hint">{{ t('admin.accounts.priorityHint') }}</p> <p class="input-hint">{{ t('admin.accounts.priorityHint') }}</p>
</div> </div>
</div> </div>
<!-- Group Selection --> <!-- Group Selection -->
<GroupSelector <GroupSelector v-model="form.group_ids" :groups="groups" :platform="form.platform" />
v-model="form.group_ids"
:groups="groups"
:platform="form.platform"
/>
<div class="flex justify-end gap-3 pt-4"> <div class="flex justify-end gap-3 pt-4">
<button <button @click="handleClose" type="button" class="btn btn-secondary">
@click="handleClose"
type="button"
class="btn btn-secondary"
>
{{ t('common.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button <button type="submit" :disabled="submitting" class="btn btn-primary">
type="submit"
:disabled="submitting"
class="btn btn-primary"
>
<svg <svg
v-if="submitting" 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" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <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> 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>
{{ 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> </button>
</div> </div>
</form> </form>
...@@ -589,26 +1003,23 @@ ...@@ -589,26 +1003,23 @@
<div v-else class="space-y-5"> <div v-else class="space-y-5">
<OAuthAuthorizationFlow <OAuthAuthorizationFlow
ref="oauthFlowRef" ref="oauthFlowRef"
:add-method="form.platform === 'openai' ? 'oauth' : addMethod" :add-method="form.platform === 'anthropic' ? addMethod : 'oauth'"
:auth-url="currentAuthUrl" :auth-url="currentAuthUrl"
:session-id="currentSessionId" :session-id="currentSessionId"
:loading="currentOAuthLoading" :loading="currentOAuthLoading"
:error="currentOAuthError" :error="currentOAuthError"
:show-help="form.platform !== 'openai'" :show-help="form.platform === 'anthropic'"
:show-proxy-warning="!!form.proxy_id" :show-proxy-warning="form.platform !== 'openai' && !!form.proxy_id"
:allow-multiple="form.platform !== 'openai'" :allow-multiple="form.platform === 'anthropic'"
:show-cookie-option="form.platform !== 'openai'" :show-cookie-option="form.platform === 'anthropic'"
:platform="form.platform" :platform="form.platform"
:show-project-id="geminiOAuthType === 'code_assist'"
@generate-url="handleGenerateUrl" @generate-url="handleGenerateUrl"
@cookie-auth="handleCookieAuth" @cookie-auth="handleCookieAuth"
/> />
<div class="flex justify-between gap-3 pt-4"> <div class="flex justify-between gap-3 pt-4">
<button <button type="button" class="btn btn-secondary" @click="goBackToBasicInfo">
type="button"
class="btn btn-secondary"
@click="goBackToBasicInfo"
>
{{ t('common.back') }} {{ t('common.back') }}
</button> </button>
<button <button
...@@ -620,14 +1031,29 @@ ...@@ -620,14 +1031,29 @@
> >
<svg <svg
v-if="currentOAuthLoading" 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" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <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> 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>
{{ currentOAuthLoading ? t('admin.accounts.oauth.verifying') : t('admin.accounts.oauth.completeAuth') }} {{
currentOAuthLoading
? t('admin.accounts.oauth.verifying')
: t('admin.accounts.oauth.completeAuth')
}}
</button> </button>
</div> </div>
</div> </div>
...@@ -639,8 +1065,13 @@ import { ref, reactive, computed, watch } from 'vue' ...@@ -639,8 +1065,13 @@ import { ref, reactive, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin' 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 { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
import type { Proxy, Group, AccountPlatform, AccountType } from '@/types' import type { Proxy, Group, AccountPlatform, AccountType } from '@/types'
import Modal from '@/components/common/Modal.vue' import Modal from '@/components/common/Modal.vue'
import ProxySelector from '@/components/common/ProxySelector.vue' import ProxySelector from '@/components/common/ProxySelector.vue'
...@@ -651,6 +1082,8 @@ import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue' ...@@ -651,6 +1082,8 @@ import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
// Note: defineExpose automatically unwraps refs, so we use the unwrapped types // Note: defineExpose automatically unwraps refs, so we use the unwrapped types
interface OAuthFlowExposed { interface OAuthFlowExposed {
authCode: string authCode: string
oauthState: string
projectId: string
sessionKey: string sessionKey: string
inputMethod: AuthInputMethod inputMethod: AuthInputMethod
reset: () => void reset: () => void
...@@ -658,6 +1091,12 @@ interface OAuthFlowExposed { ...@@ -658,6 +1091,12 @@ interface OAuthFlowExposed {
const { t } = useI18n() 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 { interface Props {
show: boolean show: boolean
proxies: Proxy[] proxies: Proxy[]
...@@ -673,24 +1112,33 @@ const emit = defineEmits<{ ...@@ -673,24 +1112,33 @@ const emit = defineEmits<{
const appStore = useAppStore() const appStore = useAppStore()
// OAuth composables // OAuth composables
const oauth = useAccountOAuth() // For Anthropic OAuth const oauth = useAccountOAuth() // For Anthropic OAuth
const openaiOAuth = useOpenAIOAuth() // For OpenAI OAuth const openaiOAuth = useOpenAIOAuth() // For OpenAI OAuth
const geminiOAuth = useGeminiOAuth() // For Gemini OAuth
// Computed: current OAuth state for template binding // Computed: current OAuth state for template binding
const currentAuthUrl = computed(() => { 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(() => { 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(() => { 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(() => { 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 // Refs
...@@ -716,6 +1164,8 @@ const customErrorCodesEnabled = ref(false) ...@@ -716,6 +1164,8 @@ const customErrorCodesEnabled = ref(false)
const selectedErrorCodes = ref<number[]>([]) const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null) const customErrorCodeInput = ref<number | null>(null)
const interceptWarmupRequests = ref(false) const interceptWarmupRequests = ref(false)
const geminiOAuthType = ref<'code_assist' | 'ai_studio'>('code_assist')
const geminiAIStudioOAuthEnabled = ref(false)
// Common models for whitelist - Anthropic // Common models for whitelist - Anthropic
const anthropicModels = [ const anthropicModels = [
...@@ -740,34 +1190,143 @@ const openaiModels = [ ...@@ -740,34 +1190,143 @@ const openaiModels = [
{ value: 'gpt-5-2025-08-07', label: 'GPT-5' } { 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 // Computed: current models based on platform
const commonModels = computed(() => { 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 // Preset mappings for quick add - Anthropic
const anthropicPresetMappings = [ 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: 'Sonnet 4',
{ 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' }, from: 'claude-sonnet-4-20250514',
{ 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' }, to: 'claude-sonnet-4-20250514',
{ 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' }, color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-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.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 // Preset mappings for quick add - OpenAI
const openaiPresetMappings = [ 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.2',
{ 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' }, from: 'gpt-5.2-2025-12-11',
{ 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' }, to: 'gpt-5.2-2025-12-11',
{ 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' }, color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-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 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 // Computed: current preset mappings based on platform
const presetMappings = computed(() => { 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 // Common HTTP error codes for quick selection
...@@ -804,38 +1363,84 @@ const canExchangeCode = computed(() => { ...@@ -804,38 +1363,84 @@ const canExchangeCode = computed(() => {
if (form.platform === 'openai') { if (form.platform === 'openai') {
return authCode.trim() && openaiOAuth.sessionId.value && !openaiOAuth.loading.value 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 return authCode.trim() && oauth.sessionId.value && !oauth.loading.value
}) })
// Watchers // Watchers
watch(() => props.show, (newVal) => { watch(
if (!newVal) { () => props.show,
resetForm() (newVal) => {
if (!newVal) {
resetForm()
}
} }
}) )
// Sync form.type based on accountCategory and addMethod // Sync form.type based on accountCategory and addMethod
watch([accountCategory, addMethod], ([category, method]) => { watch(
if (category === 'oauth-based') { [accountCategory, addMethod],
form.type = method as AccountType // 'oauth' or 'setup-token' ([category, method]) => {
} else { if (category === 'oauth-based') {
form.type = 'apikey' form.type = method as AccountType // 'oauth' or 'setup-token'
} } else {
}, { immediate: true }) form.type = 'apikey'
}
},
{ immediate: true }
)
// Reset platform-specific settings when platform changes // Reset platform-specific settings when platform changes
watch(() => form.platform, (newPlatform) => { watch(
// Reset base URL based on platform () => form.platform,
apiKeyBaseUrl.value = newPlatform === 'openai' (newPlatform) => {
? 'https://api.openai.com' // Reset base URL based on platform
: 'https://api.anthropic.com' apiKeyBaseUrl.value =
// Clear model-related settings newPlatform === 'openai'
allowedModels.value = [] ? 'https://api.openai.com'
modelMappings.value = [] : newPlatform === 'gemini'
// Reset OAuth states ? 'https://generativelanguage.googleapis.com'
oauth.resetState() : 'https://api.anthropic.com'
openaiOAuth.resetState() // 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 // Model mapping helpers
const addModelMapping = () => { const addModelMapping = () => {
...@@ -848,7 +1453,7 @@ const removeModelMapping = (index: number) => { ...@@ -848,7 +1453,7 @@ const removeModelMapping = (index: number) => {
const addPresetMapping = (from: string, to: string) => { const addPresetMapping = (from: string, to: string) => {
// Check if mapping already exists // 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) { if (exists) {
appStore.showInfo(t('admin.accounts.mappingExists', { model: from })) appStore.showInfo(t('admin.accounts.mappingExists', { model: from }))
return return
...@@ -933,8 +1538,10 @@ const resetForm = () => { ...@@ -933,8 +1538,10 @@ const resetForm = () => {
selectedErrorCodes.value = [] selectedErrorCodes.value = []
customErrorCodeInput.value = null customErrorCodeInput.value = null
interceptWarmupRequests.value = false interceptWarmupRequests.value = false
geminiOAuthType.value = 'code_assist'
oauth.resetState() oauth.resetState()
openaiOAuth.resetState() openaiOAuth.resetState()
geminiOAuth.resetState()
oauthFlowRef.value?.reset() oauthFlowRef.value?.reset()
} }
...@@ -960,9 +1567,12 @@ const handleSubmit = async () => { ...@@ -960,9 +1567,12 @@ const handleSubmit = async () => {
} }
// Determine default base URL based on platform // Determine default base URL based on platform
const defaultBaseUrl = form.platform === 'openai' const defaultBaseUrl =
? 'https://api.openai.com' form.platform === 'openai'
: 'https://api.anthropic.com' ? 'https://api.openai.com'
: form.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
: 'https://api.anthropic.com'
// Build credentials with optional model mapping // Build credentials with optional model mapping
const credentials: Record<string, unknown> = { const credentials: Record<string, unknown> = {
...@@ -1009,12 +1619,15 @@ const goBackToBasicInfo = () => { ...@@ -1009,12 +1619,15 @@ const goBackToBasicInfo = () => {
step.value = 1 step.value = 1
oauth.resetState() oauth.resetState()
openaiOAuth.resetState() openaiOAuth.resetState()
geminiOAuth.resetState()
oauthFlowRef.value?.reset() oauthFlowRef.value?.reset()
} }
const handleGenerateUrl = async () => { const handleGenerateUrl = async () => {
if (form.platform === 'openai') { if (form.platform === 'openai') {
await openaiOAuth.generateAuthUrl(form.proxy_id) await openaiOAuth.generateAuthUrl(form.proxy_id)
} else if (form.platform === 'gemini') {
await geminiOAuth.generateAuthUrl(form.proxy_id, oauthFlowRef.value?.projectId, geminiOAuthType.value)
} else { } else {
await oauth.generateAuthUrl(addMethod.value, form.proxy_id) await oauth.generateAuthUrl(addMethod.value, form.proxy_id)
} }
...@@ -1044,10 +1657,7 @@ const handleExchangeCode = async () => { ...@@ -1044,10 +1657,7 @@ const handleExchangeCode = async () => {
const credentials = openaiOAuth.buildCredentials(tokenInfo) const credentials = openaiOAuth.buildCredentials(tokenInfo)
const extra = openaiOAuth.buildExtraInfo(tokenInfo) const extra = openaiOAuth.buildExtraInfo(tokenInfo)
// Merge interceptWarmupRequests into credentials // Note: intercept_warmup_requests is Anthropic-only, not applicable to OpenAI
if (interceptWarmupRequests.value) {
credentials.intercept_warmup_requests = true
}
await adminAPI.accounts.create({ await adminAPI.accounts.create({
name: form.name, name: form.name,
...@@ -1073,6 +1683,58 @@ const handleExchangeCode = async () => { ...@@ -1073,6 +1683,58 @@ const handleExchangeCode = async () => {
return 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 // For Anthropic
if (!authCode.trim() || !oauth.sessionId.value) return if (!authCode.trim() || !oauth.sessionId.value) return
...@@ -1081,9 +1743,10 @@ const handleExchangeCode = async () => { ...@@ -1081,9 +1743,10 @@ const handleExchangeCode = async () => {
try { try {
const proxyConfig = form.proxy_id ? { proxy_id: form.proxy_id } : {} const proxyConfig = form.proxy_id ? { proxy_id: form.proxy_id } : {}
const endpoint = addMethod.value === 'oauth' const endpoint =
? '/admin/accounts/exchange-code' addMethod.value === 'oauth'
: '/admin/accounts/exchange-setup-token-code' ? '/admin/accounts/exchange-code'
: '/admin/accounts/exchange-setup-token-code'
const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, { const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, {
session_id: oauth.sessionId.value, session_id: oauth.sessionId.value,
...@@ -1135,9 +1798,10 @@ const handleCookieAuth = async (sessionKey: string) => { ...@@ -1135,9 +1798,10 @@ const handleCookieAuth = async (sessionKey: string) => {
return return
} }
const endpoint = addMethod.value === 'oauth' const endpoint =
? '/admin/accounts/cookie-auth' addMethod.value === 'oauth'
: '/admin/accounts/setup-token-cookie-auth' ? '/admin/accounts/cookie-auth'
: '/admin/accounts/setup-token-cookie-auth'
let successCount = 0 let successCount = 0
let failedCount = 0 let failedCount = 0
...@@ -1174,7 +1838,12 @@ const handleCookieAuth = async (sessionKey: string) => { ...@@ -1174,7 +1838,12 @@ const handleCookieAuth = async (sessionKey: string) => {
successCount++ successCount++
} catch (error: any) { } catch (error: any) {
failedCount++ 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> <template>
<Modal <Modal :show="show" :title="t('admin.accounts.editAccount')" size="lg" @close="handleClose">
:show="show"
:title="t('admin.accounts.editAccount')"
size="lg"
@close="handleClose"
>
<form v-if="account" @submit.prevent="handleSubmit" class="space-y-5"> <form v-if="account" @submit.prevent="handleSubmit" class="space-y-5">
<div> <div>
<label class="input-label">{{ t('common.name') }}</label> <label class="input-label">{{ t('common.name') }}</label>
<input <input v-model="form.name" type="text" required class="input" />
v-model="form.name"
type="text"
required
class="input"
/>
</div> </div>
<!-- API Key fields (only for apikey type) --> <!-- API Key fields (only for apikey type) -->
...@@ -24,7 +14,13 @@ ...@@ -24,7 +14,13 @@
v-model="editBaseUrl" v-model="editBaseUrl"
type="text" type="text"
class="input" 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> <p class="input-hint">{{ t('admin.accounts.baseUrlHint') }}</p>
</div> </div>
...@@ -34,17 +30,23 @@ ...@@ -34,17 +30,23 @@
v-model="editApiKey" v-model="editApiKey"
type="password" type="password"
class="input font-mono" 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> <p class="input-hint">{{ t('admin.accounts.leaveEmptyToKeep') }}</p>
</div> </div>
<!-- Model Restriction Section --> <!-- Model Restriction Section (不适用于 Gemini) -->
<div class="border-t border-gray-200 dark:border-dark-600 pt-4"> <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> <label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
<!-- Mode Toggle --> <!-- Mode Toggle -->
<div class="flex gap-2 mb-4"> <div class="mb-4 flex gap-2">
<button <button
type="button" type="button"
@click="modelRestrictionMode = 'whitelist'" @click="modelRestrictionMode = 'whitelist'"
...@@ -55,8 +57,18 @@ ...@@ -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' : '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"> <svg
<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" /> 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> </svg>
{{ t('admin.accounts.modelWhitelist') }} {{ t('admin.accounts.modelWhitelist') }}
</button> </button>
...@@ -70,8 +82,18 @@ ...@@ -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' : '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"> <svg
<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" /> 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> </svg>
{{ t('admin.accounts.modelMapping') }} {{ t('admin.accounts.modelMapping') }}
</button> </button>
...@@ -79,22 +101,36 @@ ...@@ -79,22 +101,36 @@
<!-- Whitelist Mode --> <!-- Whitelist Mode -->
<div v-if="modelRestrictionMode === 'whitelist'"> <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"> <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"> <svg
<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" /> 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> </svg>
{{ t('admin.accounts.selectAllowedModels') }} {{ t('admin.accounts.selectAllowedModels') }}
</p> </p>
</div> </div>
<!-- Model Checkbox List --> <!-- Model Checkbox List -->
<div class="grid grid-cols-2 gap-2 mb-3"> <div class="mb-3 grid grid-cols-2 gap-2">
<label <label
v-for="model in commonModels" v-for="model in commonModels"
:key="model.value" :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="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 <input
type="checkbox" type="checkbox"
...@@ -108,23 +144,35 @@ ...@@ -108,23 +144,35 @@
<p class="text-xs text-gray-500 dark:text-gray-400"> <p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }} {{ 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> </p>
</div> </div>
<!-- Mapping Mode --> <!-- Mapping Mode -->
<div v-else> <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"> <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"> <svg
<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" /> 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> </svg>
{{ t('admin.accounts.mapRequestModels') }} {{ t('admin.accounts.mapRequestModels') }}
</p> </p>
</div> </div>
<!-- Model Mapping List --> <!-- 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 <div
v-for="(mapping, index) in modelMappings" v-for="(mapping, index) in modelMappings"
:key="index" :key="index"
...@@ -136,8 +184,18 @@ ...@@ -136,8 +184,18 @@
class="input flex-1" class="input flex-1"
:placeholder="t('admin.accounts.requestModel')" :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"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" /> 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> </svg>
<input <input
v-model="mapping.to" v-model="mapping.to"
...@@ -148,10 +206,15 @@ ...@@ -148,10 +206,15 @@
<button <button
type="button" type="button"
@click="removeModelMapping(index)" @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"> <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" /> <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> </svg>
</button> </button>
</div> </div>
...@@ -160,10 +223,20 @@ ...@@ -160,10 +223,20 @@
<button <button
type="button" type="button"
@click="addModelMapping" @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="w-4 h-4 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /> 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 4v16m8-8H4"
/>
</svg> </svg>
{{ t('admin.accounts.addMapping') }} {{ t('admin.accounts.addMapping') }}
</button> </button>
...@@ -175,10 +248,7 @@ ...@@ -175,10 +248,7 @@
:key="preset.label" :key="preset.label"
type="button" type="button"
@click="addPresetMapping(preset.from, preset.to)" @click="addPresetMapping(preset.from, preset.to)"
:class="[ :class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
'rounded-lg px-3 py-1 text-xs transition-colors',
preset.color
]"
> >
+ {{ preset.label }} + {{ preset.label }}
</button> </button>
...@@ -187,11 +257,13 @@ ...@@ -187,11 +257,13 @@
</div> </div>
<!-- Custom Error Codes Section --> <!-- Custom Error Codes Section -->
<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 mb-3"> <div class="mb-3 flex items-center justify-between">
<div> <div>
<label class="input-label mb-0">{{ t('admin.accounts.customErrorCodes') }}</label> <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> </div>
<button <button
type="button" type="button"
...@@ -211,10 +283,20 @@ ...@@ -211,10 +283,20 @@
</div> </div>
<div v-if="customErrorCodesEnabled" class="space-y-3"> <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"> <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"> <svg
<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" /> 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> </svg>
{{ t('admin.accounts.customErrorCodesWarning') }} {{ t('admin.accounts.customErrorCodesWarning') }}
</p> </p>
...@@ -230,7 +312,7 @@ ...@@ -230,7 +312,7 @@
:class="[ :class="[
'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors', 'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
selectedErrorCodes.includes(code.value) 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' : '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 @@ ...@@ -249,13 +331,14 @@
:placeholder="t('admin.accounts.enterErrorCode')" :placeholder="t('admin.accounts.enterErrorCode')"
@keyup.enter="addCustomErrorCode" @keyup.enter="addCustomErrorCode"
/> />
<button <button type="button" @click="addCustomErrorCode" class="btn btn-secondary px-3">
type="button" <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@click="addCustomErrorCode" <path
class="btn btn-secondary px-3" stroke-linecap="round"
> stroke-linejoin="round"
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> stroke-width="2"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /> d="M12 4v16m8-8H4"
/>
</svg> </svg>
</button> </button>
</div> </div>
...@@ -265,7 +348,7 @@ ...@@ -265,7 +348,7 @@
<span <span
v-for="code in selectedErrorCodes.sort((a, b) => a - b)" v-for="code in selectedErrorCodes.sort((a, b) => a - b)"
:key="code" :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 }} {{ code }}
<button <button
...@@ -273,8 +356,13 @@ ...@@ -273,8 +356,13 @@
@click="removeErrorCode(code)" @click="removeErrorCode(code)"
class="hover:text-red-900 dark:hover:text-red-300" 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"> <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" /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg> </svg>
</button> </button>
</span> </span>
...@@ -284,14 +372,50 @@ ...@@ -284,14 +372,50 @@
</div> </div>
</div> </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> </div>
<!-- Intercept Warmup Requests (Anthropic only) --> <!-- 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 class="flex items-center justify-between">
<div> <div>
<label class="input-label mb-0">{{ t('admin.accounts.interceptWarmupRequests') }}</label> <label class="input-label mb-0">{{
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.accounts.interceptWarmupRequestsDesc') }}</p> t('admin.accounts.interceptWarmupRequests')
}}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.interceptWarmupRequestsDesc') }}
</p>
</div> </div>
<button <button
type="button" type="button"
...@@ -313,69 +437,52 @@ ...@@ -313,69 +437,52 @@
<div> <div>
<label class="input-label">{{ t('admin.accounts.proxy') }}</label> <label class="input-label">{{ t('admin.accounts.proxy') }}</label>
<ProxySelector <ProxySelector v-model="form.proxy_id" :proxies="proxies" />
v-model="form.proxy_id"
:proxies="proxies"
/>
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div> <div>
<label class="input-label">{{ t('admin.accounts.concurrency') }}</label> <label class="input-label">{{ t('admin.accounts.concurrency') }}</label>
<input <input v-model.number="form.concurrency" type="number" min="1" class="input" />
v-model.number="form.concurrency"
type="number"
min="1"
class="input"
/>
</div> </div>
<div> <div>
<label class="input-label">{{ t('admin.accounts.priority') }}</label> <label class="input-label">{{ t('admin.accounts.priority') }}</label>
<input <input v-model.number="form.priority" type="number" min="1" class="input" />
v-model.number="form.priority"
type="number"
min="1"
class="input"
/>
</div> </div>
</div> </div>
<div> <div>
<label class="input-label">{{ t('common.status') }}</label> <label class="input-label">{{ t('common.status') }}</label>
<Select <Select v-model="form.status" :options="statusOptions" />
v-model="form.status"
:options="statusOptions"
/>
</div> </div>
<!-- Group Selection --> <!-- Group Selection -->
<GroupSelector <GroupSelector v-model="form.group_ids" :groups="groups" :platform="account?.platform" />
v-model="form.group_ids"
:groups="groups"
:platform="account?.platform"
/>
<div class="flex justify-end gap-3 pt-4"> <div class="flex justify-end gap-3 pt-4">
<button <button @click="handleClose" type="button" class="btn btn-secondary">
@click="handleClose"
type="button"
class="btn btn-secondary"
>
{{ t('common.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button <button type="submit" :disabled="submitting" class="btn btn-primary">
type="submit"
:disabled="submitting"
class="btn btn-primary"
>
<svg <svg
v-if="submitting" 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" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <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> 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>
{{ submitting ? t('admin.accounts.updating') : t('common.update') }} {{ submitting ? t('admin.accounts.updating') : t('common.update') }}
</button> </button>
...@@ -452,39 +559,150 @@ const openaiModels = [ ...@@ -452,39 +559,150 @@ const openaiModels = [
{ value: 'gpt-5-2025-08-07', label: 'GPT-5' } { 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 // Computed: current models based on platform
const commonModels = computed(() => { 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 // Preset mappings for quick add - Anthropic
const anthropicPresetMappings = [ 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: 'Sonnet 4',
{ 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' }, from: 'claude-sonnet-4-20250514',
{ 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' }, to: 'claude-sonnet-4-20250514',
{ 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' }, color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-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.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 // Preset mappings for quick add - OpenAI
const openaiPresetMappings = [ 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.2',
{ 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' }, from: 'gpt-5.2-2025-12-11',
{ 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' }, to: 'gpt-5.2-2025-12-11',
{ 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' }, color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-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 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 // Computed: current preset mappings based on platform
const presetMappings = computed(() => { 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 // Computed: default base URL based on platform
const defaultBaseUrl = computed(() => { 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 // Common HTTP error codes for quick selection
...@@ -513,71 +731,85 @@ const statusOptions = computed(() => [ ...@@ -513,71 +731,85 @@ const statusOptions = computed(() => [
]) ])
// Watchers // Watchers
watch(() => props.account, (newAccount) => { watch(
if (newAccount) { () => props.account,
form.name = newAccount.name (newAccount) => {
form.proxy_id = newAccount.proxy_id if (newAccount) {
form.concurrency = newAccount.concurrency form.name = newAccount.name
form.priority = newAccount.priority form.proxy_id = newAccount.proxy_id
form.status = newAccount.status as 'active' | 'inactive' form.concurrency = newAccount.concurrency
form.group_ids = newAccount.group_ids || [] form.priority = newAccount.priority
form.status = newAccount.status as 'active' | 'inactive'
// Load intercept warmup requests setting (applies to all account types) form.group_ids = newAccount.group_ids || []
const credentials = newAccount.credentials as Record<string, unknown> | undefined
interceptWarmupRequests.value = credentials?.intercept_warmup_requests === true // Load intercept warmup requests setting (applies to all account types)
const credentials = newAccount.credentials as Record<string, unknown> | undefined
// Initialize API Key fields for apikey type interceptWarmupRequests.value = credentials?.intercept_warmup_requests === true
if (newAccount.type === 'apikey' && newAccount.credentials) {
const credentials = newAccount.credentials as Record<string, unknown> // Initialize API Key fields for apikey type
const platformDefaultUrl = newAccount.platform === 'openai' ? 'https://api.openai.com' : 'https://api.anthropic.com' if (newAccount.type === 'apikey' && newAccount.credentials) {
editBaseUrl.value = credentials.base_url as string || platformDefaultUrl const credentials = newAccount.credentials as Record<string, unknown>
const platformDefaultUrl =
// Load model mappings and detect mode newAccount.platform === 'openai'
const existingMappings = credentials.model_mapping as Record<string, string> | undefined ? 'https://api.openai.com'
if (existingMappings && typeof existingMappings === 'object') { : newAccount.platform === 'gemini'
const entries = Object.entries(existingMappings) ? 'https://generativelanguage.googleapis.com'
: 'https://api.anthropic.com'
// Detect if this is whitelist mode (all from === to) or mapping mode editBaseUrl.value = (credentials.base_url as string) || platformDefaultUrl
const isWhitelistMode = entries.length > 0 && entries.every(([from, to]) => from === to)
// Load model mappings and detect mode
if (isWhitelistMode) { const existingMappings = credentials.model_mapping as Record<string, string> | undefined
// Whitelist mode: populate allowedModels if (existingMappings && typeof existingMappings === 'object') {
const entries = Object.entries(existingMappings)
// Detect if this is whitelist mode (all from === to) or mapping mode
const isWhitelistMode = entries.length > 0 && entries.every(([from, to]) => from === to)
if (isWhitelistMode) {
// Whitelist mode: populate allowedModels
modelRestrictionMode.value = 'whitelist'
allowedModels.value = entries.map(([from]) => from)
modelMappings.value = []
} else {
// Mapping mode: populate modelMappings
modelRestrictionMode.value = 'mapping'
modelMappings.value = entries.map(([from, to]) => ({ from, to }))
allowedModels.value = []
}
} else {
// No mappings: default to whitelist mode with empty selection (allow all)
modelRestrictionMode.value = 'whitelist' modelRestrictionMode.value = 'whitelist'
allowedModels.value = entries.map(([from]) => from)
modelMappings.value = [] modelMappings.value = []
} else {
// Mapping mode: populate modelMappings
modelRestrictionMode.value = 'mapping'
modelMappings.value = entries.map(([from, to]) => ({ from, to }))
allowedModels.value = [] allowedModels.value = []
} }
// Load custom error codes
customErrorCodesEnabled.value = credentials.custom_error_codes_enabled === true
const existingErrorCodes = credentials.custom_error_codes as number[] | undefined
if (existingErrorCodes && Array.isArray(existingErrorCodes)) {
selectedErrorCodes.value = [...existingErrorCodes]
} else {
selectedErrorCodes.value = []
}
} else { } else {
// No mappings: default to whitelist mode with empty selection (allow all) 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' modelRestrictionMode.value = 'whitelist'
modelMappings.value = [] modelMappings.value = []
allowedModels.value = [] allowedModels.value = []
} customErrorCodesEnabled.value = false
// Load custom error codes
customErrorCodesEnabled.value = credentials.custom_error_codes_enabled === true
const existingErrorCodes = credentials.custom_error_codes as number[] | undefined
if (existingErrorCodes && Array.isArray(existingErrorCodes)) {
selectedErrorCodes.value = [...existingErrorCodes]
} else {
selectedErrorCodes.value = [] selectedErrorCodes.value = []
} }
} else { editApiKey.value = ''
const platformDefaultUrl = newAccount.platform === 'openai' ? 'https://api.openai.com' : 'https://api.anthropic.com'
editBaseUrl.value = platformDefaultUrl
modelRestrictionMode.value = 'whitelist'
modelMappings.value = []
allowedModels.value = []
customErrorCodesEnabled.value = false
selectedErrorCodes.value = []
} }
editApiKey.value = '' },
} { immediate: true }
}, { immediate: true }) )
// Model mapping helpers // Model mapping helpers
const addModelMapping = () => { const addModelMapping = () => {
...@@ -589,7 +821,7 @@ const removeModelMapping = (index: number) => { ...@@ -589,7 +821,7 @@ const removeModelMapping = (index: number) => {
} }
const addPresetMapping = (from: string, to: string) => { 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) { if (exists) {
appStore.showInfo(t('admin.accounts.mappingExists', { model: from })) appStore.showInfo(t('admin.accounts.mappingExists', { model: from }))
return return
...@@ -666,7 +898,7 @@ const handleSubmit = async () => { ...@@ -666,7 +898,7 @@ const handleSubmit = async () => {
// For apikey type, handle credentials update // For apikey type, handle credentials update
if (props.account.type === 'apikey') { 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 newBaseUrl = editBaseUrl.value.trim() || defaultBaseUrl.value
const modelMapping = buildModelMappingObject() const modelMapping = buildModelMappingObject()
...@@ -707,7 +939,7 @@ const handleSubmit = async () => { ...@@ -707,7 +939,7 @@ const handleSubmit = async () => {
updatePayload.credentials = newCredentials updatePayload.credentials = newCredentials
} else { } else {
// For oauth/setup-token types, only update intercept_warmup_requests if changed // 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 } const newCredentials: Record<string, unknown> = { ...currentCredentials }
if (interceptWarmupRequests.value) { if (interceptWarmupRequests.value) {
......
<template> <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 items-start gap-4">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-blue-500"> <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"> <svg
<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" /> 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> </svg>
</div> </div>
<div class="flex-1"> <div class="flex-1">
...@@ -22,7 +34,9 @@ ...@@ -22,7 +34,9 @@
value="manual" value="manual"
class="text-blue-600 focus:ring-blue-500" 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>
<label class="flex cursor-pointer items-center gap-2"> <label class="flex cursor-pointer items-center gap-2">
<input <input
...@@ -31,23 +45,39 @@ ...@@ -31,23 +45,39 @@
value="cookie" value="cookie"
class="text-blue-600 focus:ring-blue-500" 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> </label>
</div> </div>
</div> </div>
<!-- Cookie Auto-Auth Form --> <!-- Cookie Auto-Auth Form -->
<div v-if="inputMethod === 'cookie'" class="space-y-4"> <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"> <p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
{{ t('admin.accounts.oauth.cookieAutoAuthDesc') }} {{ t('admin.accounts.oauth.cookieAutoAuthDesc') }}
</p> </p>
<!-- sessionKey Input --> <!-- sessionKey Input -->
<div class="mb-4"> <div class="mb-4">
<label class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"> <label
<svg class="w-4 h-4 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
<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="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> </svg>
{{ t('admin.accounts.oauth.sessionKey') }} {{ t('admin.accounts.oauth.sessionKey') }}
<span <span
...@@ -62,16 +92,30 @@ ...@@ -62,16 +92,30 @@
class="text-blue-500 hover:text-blue-600" class="text-blue-500 hover:text-blue-600"
@click="showHelpDialog = !showHelpDialog" @click="showHelpDialog = !showHelpDialog"
> >
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> <svg
<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" /> 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> </svg>
</button> </button>
</label> </label>
<textarea <textarea
v-model="sessionKeyInput" v-model="sessionKeyInput"
rows="3" rows="3"
class="input w-full font-mono text-sm resize-y" class="input w-full resize-y font-mono text-sm"
:placeholder="allowMultiple ? t('admin.accounts.oauth.sessionKeyPlaceholder') : t('admin.accounts.oauth.sessionKeyPlaceholderSingle')" :placeholder="
allowMultiple
? t('admin.accounts.oauth.sessionKeyPlaceholder')
: t('admin.accounts.oauth.sessionKeyPlaceholderSingle')
"
></textarea> ></textarea>
<p <p
v-if="parsedKeyCount > 1 && allowMultiple" v-if="parsedKeyCount > 1 && allowMultiple"
...@@ -84,12 +128,14 @@ ...@@ -84,12 +128,14 @@
<!-- Help Section --> <!-- Help Section -->
<div <div
v-if="showHelpDialog && showHelp" 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"> <h5 class="mb-2 font-semibold text-amber-800 dark:text-amber-200">
{{ t('admin.accounts.oauth.howToGetSessionKey') }} {{ t('admin.accounts.oauth.howToGetSessionKey') }}
</h5> </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.step1')"></li>
<li v-html="t('admin.accounts.oauth.step2')"></li> <li v-html="t('admin.accounts.oauth.step2')"></li>
<li v-html="t('admin.accounts.oauth.step3')"></li> <li v-html="t('admin.accounts.oauth.step3')"></li>
...@@ -97,15 +143,18 @@ ...@@ -97,15 +143,18 @@
<li v-html="t('admin.accounts.oauth.step5')"></li> <li v-html="t('admin.accounts.oauth.step5')"></li>
<li v-html="t('admin.accounts.oauth.step6')"></li> <li v-html="t('admin.accounts.oauth.step6')"></li>
</ol> </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> </div>
<!-- Error Message --> <!-- Error Message -->
<div <div
v-if="error" 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 }} {{ error }}
</p> </p>
</div> </div>
...@@ -117,14 +166,45 @@ ...@@ -117,14 +166,45 @@
:disabled="loading || !sessionKeyInput.trim()" :disabled="loading || !sessionKeyInput.trim()"
@click="handleCookieAuth" @click="handleCookieAuth"
> >
<svg v-if="loading" class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24"> <svg
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> v-if="loading"
<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> 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>
<svg v-else class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> <svg
<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" /> 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> </svg>
{{ loading ? t('admin.accounts.oauth.authorizing') : t('admin.accounts.oauth.startAutoAuth') }} {{
loading
? t('admin.accounts.oauth.authorizing')
: t('admin.accounts.oauth.startAutoAuth')
}}
</button> </button>
</div> </div>
</div> </div>
...@@ -136,15 +216,44 @@ ...@@ -136,15 +216,44 @@
</p> </p>
<!-- Step 1: Generate Auth URL --> <!-- 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 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 1
</div> </div>
<div class="flex-1"> <div class="flex-1">
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200"> <p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
{{ oauthStep1GenerateUrl }} {{ oauthStep1GenerateUrl }}
</p> </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 <button
v-if="!authUrl" v-if="!authUrl"
type="button" type="button"
...@@ -152,12 +261,39 @@ ...@@ -152,12 +261,39 @@
class="btn btn-primary text-sm" class="btn btn-primary text-sm"
@click="handleGenerateUrl" @click="handleGenerateUrl"
> >
<svg v-if="loading" class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24"> <svg
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> v-if="loading"
<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> 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>
<svg v-else class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> <svg
<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" /> 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> </svg>
{{ loading ? t('admin.accounts.oauth.generating') : oauthGenerateAuthUrl }} {{ loading ? t('admin.accounts.oauth.generating') : oauthGenerateAuthUrl }}
</button> </button>
...@@ -167,7 +303,7 @@ ...@@ -167,7 +303,7 @@
:value="authUrl" :value="authUrl"
readonly readonly
type="text" 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 <button
type="button" type="button"
...@@ -175,11 +311,33 @@ ...@@ -175,11 +311,33 @@
title="Copy URL" title="Copy URL"
@click="handleCopyUrl" @click="handleCopyUrl"
> >
<svg v-if="!copied" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> <svg
<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" /> 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>
<svg v-else class="w-4 h-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <svg
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /> 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> </svg>
</button> </button>
</div> </div>
...@@ -188,8 +346,18 @@ ...@@ -188,8 +346,18 @@
class="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400" class="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400"
@click="handleRegenerate" @click="handleRegenerate"
> >
<svg class="w-3 h-3 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> <svg
<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" /> 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> </svg>
{{ t('admin.accounts.oauth.regenerate') }} {{ t('admin.accounts.oauth.regenerate') }}
</button> </button>
...@@ -199,9 +367,13 @@ ...@@ -199,9 +367,13 @@
</div> </div>
<!-- Step 2: Open URL and authorize --> <!-- 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 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 2
</div> </div>
<div class="flex-1"> <div class="flex-1">
...@@ -212,58 +384,120 @@ ...@@ -212,58 +384,120 @@
{{ oauthOpenUrlDesc }} {{ oauthOpenUrlDesc }}
</p> </p>
<!-- OpenAI Important Notice --> <!-- 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"> <div
<p class="text-xs text-amber-800 dark:text-amber-300" v-html="oauthImportantNotice"> v-if="isOpenAI"
</p> 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> </div>
<!-- Proxy Warning (for non-OpenAI) --> <!-- 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"> <div
<p class="text-xs text-yellow-800 dark:text-yellow-300" v-html="t('admin.accounts.oauth.proxyWarning')"> v-else-if="showProxyWarning"
</p> 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>
</div> </div>
</div> </div>
<!-- Step 3: Enter authorization code --> <!-- 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 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 3
</div> </div>
<div class="flex-1"> <div class="flex-1">
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200"> <p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
{{ oauthStep3EnterCode }} {{ oauthStep3EnterCode }}
</p> </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> <div>
<label class="input-label"> <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"> <svg
<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" /> 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> </svg>
{{ oauthAuthCode }} {{ oauthAuthCode }}
</label> </label>
<textarea <textarea
v-model="authCodeInput" v-model="authCodeInput"
rows="3" rows="3"
class="input w-full font-mono text-sm resize-none" class="input w-full resize-none font-mono text-sm"
:placeholder="oauthAuthCodePlaceholder" :placeholder="oauthAuthCodePlaceholder"
></textarea> ></textarea>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> <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"> <svg
<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" /> 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> </svg>
{{ oauthAuthCodeHint }} {{ oauthAuthCodeHint }}
</p> </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> </div>
<!-- Error Message --> <!-- Error Message -->
<div <div
v-if="error" 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 }} {{ error }}
</p> </p>
</div> </div>
...@@ -293,7 +527,8 @@ interface Props { ...@@ -293,7 +527,8 @@ interface Props {
allowMultiple?: boolean allowMultiple?: boolean
methodLabel?: string methodLabel?: string
showCookieOption?: boolean // Whether to show cookie auto-auth option 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>(), { const props = withDefaults(defineProps<Props>(), {
...@@ -306,7 +541,8 @@ const props = withDefaults(defineProps<Props>(), { ...@@ -306,7 +541,8 @@ const props = withDefaults(defineProps<Props>(), {
allowMultiple: false, allowMultiple: false,
methodLabel: 'Authorization Method', methodLabel: 'Authorization Method',
showCookieOption: true, showCookieOption: true,
platform: 'anthropic' platform: 'anthropic',
showProjectId: true
}) })
const emit = defineEmits<{ const emit = defineEmits<{
...@@ -318,16 +554,12 @@ const emit = defineEmits<{ ...@@ -318,16 +554,12 @@ const emit = defineEmits<{
const { t } = useI18n() const { t } = useI18n()
// Platform-specific translation helpers
const isOpenAI = computed(() => props.platform === 'openai') const isOpenAI = computed(() => props.platform === 'openai')
// Get translation key based on platform // Get translation key based on platform
const getOAuthKey = (key: string) => { const getOAuthKey = (key: string) => {
if (isOpenAI.value) { if (props.platform === 'openai') return `admin.accounts.oauth.openai.${key}`
// Try OpenAI-specific key first if (props.platform === 'gemini') return `admin.accounts.oauth.gemini.${key}`
const openaiKey = `admin.accounts.oauth.openai.${key}`
return openaiKey
}
return `admin.accounts.oauth.${key}` return `admin.accounts.oauth.${key}`
} }
...@@ -343,20 +575,27 @@ const oauthAuthCodeDesc = computed(() => t(getOAuthKey('authCodeDesc'))) ...@@ -343,20 +575,27 @@ const oauthAuthCodeDesc = computed(() => t(getOAuthKey('authCodeDesc')))
const oauthAuthCode = computed(() => t(getOAuthKey('authCode'))) const oauthAuthCode = computed(() => t(getOAuthKey('authCode')))
const oauthAuthCodePlaceholder = computed(() => t(getOAuthKey('authCodePlaceholder'))) const oauthAuthCodePlaceholder = computed(() => t(getOAuthKey('authCodePlaceholder')))
const oauthAuthCodeHint = computed(() => t(getOAuthKey('authCodeHint'))) 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 // Local state
const inputMethod = ref<AuthInputMethod>(props.showCookieOption ? 'manual' : 'manual') const inputMethod = ref<AuthInputMethod>(props.showCookieOption ? 'manual' : 'manual')
const authCodeInput = ref('') const authCodeInput = ref('')
const sessionKeyInput = ref('') const sessionKeyInput = ref('')
const showHelpDialog = ref(false) const showHelpDialog = ref(false)
const oauthState = ref('')
const projectId = ref('')
// Clipboard // Clipboard
const { copied, copyToClipboard } = useClipboard() const { copied, copyToClipboard } = useClipboard()
// Computed // Computed
const parsedKeyCount = 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 // Watchers
...@@ -367,7 +606,7 @@ watch(inputMethod, (newVal) => { ...@@ -367,7 +606,7 @@ watch(inputMethod, (newVal) => {
// Auto-extract code from OpenAI callback URL // Auto-extract code from OpenAI callback URL
// e.g., http://localhost:1455/auth/callback?code=ac_xxx...&scope=...&state=... // e.g., http://localhost:1455/auth/callback?code=ac_xxx...&scope=...&state=...
watch(authCodeInput, (newVal) => { watch(authCodeInput, (newVal) => {
if (!isOpenAI.value) return if (props.platform !== 'openai' && props.platform !== 'gemini') return
const trimmed = newVal.trim() const trimmed = newVal.trim()
// Check if it looks like a URL with code parameter // Check if it looks like a URL with code parameter
...@@ -376,6 +615,10 @@ watch(authCodeInput, (newVal) => { ...@@ -376,6 +615,10 @@ watch(authCodeInput, (newVal) => {
// Try to parse as URL // Try to parse as URL
const url = new URL(trimmed) const url = new URL(trimmed)
const code = url.searchParams.get('code') const code = url.searchParams.get('code')
const stateParam = url.searchParams.get('state')
if (props.platform === 'gemini' && stateParam) {
oauthState.value = stateParam
}
if (code && code !== trimmed) { if (code && code !== trimmed) {
// Replace the input with just the code // Replace the input with just the code
authCodeInput.value = code authCodeInput.value = code
...@@ -383,6 +626,10 @@ watch(authCodeInput, (newVal) => { ...@@ -383,6 +626,10 @@ watch(authCodeInput, (newVal) => {
} catch { } catch {
// If URL parsing fails, try regex extraction // If URL parsing fails, try regex extraction
const match = trimmed.match(/[?&]code=([^&]+)/) 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) { if (match && match[1] && match[1] !== trimmed) {
authCodeInput.value = match[1] authCodeInput.value = match[1]
} }
...@@ -415,10 +662,14 @@ const handleCookieAuth = () => { ...@@ -415,10 +662,14 @@ const handleCookieAuth = () => {
// Expose methods and state // Expose methods and state
defineExpose({ defineExpose({
authCode: authCodeInput, authCode: authCodeInput,
oauthState,
projectId,
sessionKey: sessionKeyInput, sessionKey: sessionKeyInput,
inputMethod, inputMethod,
reset: () => { reset: () => {
authCodeInput.value = '' authCodeInput.value = ''
oauthState.value = ''
projectId.value = ''
sessionKeyInput.value = '' sessionKeyInput.value = ''
inputMethod.value = 'manual' inputMethod.value = 'manual'
showHelpDialog.value = false showHelpDialog.value = false
......
...@@ -7,29 +7,55 @@ ...@@ -7,29 +7,55 @@
> >
<div v-if="account" class="space-y-5"> <div v-if="account" class="space-y-5">
<!-- Account Info --> <!-- 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="flex items-center gap-3">
<div :class="[ <div
'flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br', :class="[
isOpenAI ? 'from-green-500 to-green-600' : 'from-orange-500 to-orange-600' 'flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br',
]"> isOpenAI
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> ? 'from-green-500 to-green-600'
<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" /> : 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> </svg>
</div> </div>
<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"> <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> </span>
</div> </div>
</div> </div>
</div> </div>
<!-- Add Method Selection (Claude only) --> <!-- Add Method Selection (Claude only) -->
<div v-if="!isOpenAI"> <div v-if="isAnthropic">
<label class="input-label">{{ t('admin.accounts.oauth.authMethod') }}</label> <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"> <label class="flex cursor-pointer items-center">
<input <input
v-model="addMethod" v-model="addMethod"
...@@ -46,12 +72,119 @@ ...@@ -46,12 +72,119 @@
value="setup-token" value="setup-token"
class="mr-2 text-primary-600 focus:ring-primary-500" 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> </label>
</div> </div>
</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 <OAuthAuthorizationFlow
ref="oauthFlowRef" ref="oauthFlowRef"
:add-method="addMethod" :add-method="addMethod"
...@@ -59,22 +192,19 @@ ...@@ -59,22 +192,19 @@
:session-id="currentSessionId" :session-id="currentSessionId"
:loading="currentLoading" :loading="currentLoading"
:error="currentError" :error="currentError"
:show-help="!isOpenAI" :show-help="isAnthropic"
:show-proxy-warning="!isOpenAI" :show-proxy-warning="isAnthropic"
:show-cookie-option="!isOpenAI" :show-cookie-option="isAnthropic"
:allow-multiple="false" :allow-multiple="false"
:method-label="t('admin.accounts.inputMethod')" :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" @generate-url="handleGenerateUrl"
@cookie-auth="handleCookieAuth" @cookie-auth="handleCookieAuth"
/> />
<div class="flex justify-between gap-3 pt-4"> <div class="flex justify-between gap-3 pt-4">
<button <button type="button" class="btn btn-secondary" @click="handleClose">
type="button"
class="btn btn-secondary"
@click="handleClose"
>
{{ t('common.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button <button
...@@ -86,14 +216,29 @@ ...@@ -86,14 +216,29 @@
> >
<svg <svg
v-if="currentLoading" 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" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <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> 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>
{{ currentLoading ? t('admin.accounts.oauth.verifying') : t('admin.accounts.oauth.completeAuth') }} {{
currentLoading
? t('admin.accounts.oauth.verifying')
: t('admin.accounts.oauth.completeAuth')
}}
</button> </button>
</div> </div>
</div> </div>
...@@ -105,8 +250,13 @@ import { ref, computed, watch } from 'vue' ...@@ -105,8 +250,13 @@ import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin' 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 { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
import type { Account } from '@/types' import type { Account } from '@/types'
import Modal from '@/components/common/Modal.vue' import Modal from '@/components/common/Modal.vue'
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue' import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
...@@ -115,6 +265,8 @@ 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 // Note: defineExpose automatically unwraps refs, so we use the unwrapped types
interface OAuthFlowExposed { interface OAuthFlowExposed {
authCode: string authCode: string
oauthState: string
projectId: string
sessionKey: string sessionKey: string
inputMethod: AuthInputMethod inputMethod: AuthInputMethod
reset: () => void reset: () => void
...@@ -137,55 +289,113 @@ const { t } = useI18n() ...@@ -137,55 +289,113 @@ const { t } = useI18n()
// OAuth composables - use both Claude and OpenAI // OAuth composables - use both Claude and OpenAI
const claudeOAuth = useAccountOAuth() const claudeOAuth = useAccountOAuth()
const openaiOAuth = useOpenAIOAuth() const openaiOAuth = useOpenAIOAuth()
const geminiOAuth = useGeminiOAuth()
// Refs // Refs
const oauthFlowRef = ref<OAuthFlowExposed | null>(null) const oauthFlowRef = ref<OAuthFlowExposed | null>(null)
// State // State
const addMethod = ref<AddMethod>('oauth') 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 // Computed - check if this is an OpenAI account
const isOpenAI = computed(() => props.account?.platform === 'openai') 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 // Computed - current OAuth state based on platform
const currentAuthUrl = computed(() => isOpenAI.value ? openaiOAuth.authUrl.value : claudeOAuth.authUrl.value) const currentAuthUrl = computed(() => {
const currentSessionId = computed(() => isOpenAI.value ? openaiOAuth.sessionId.value : claudeOAuth.sessionId.value) if (isOpenAI.value) return openaiOAuth.authUrl.value
const currentLoading = computed(() => isOpenAI.value ? openaiOAuth.loading.value : claudeOAuth.loading.value) if (isGemini.value) return geminiOAuth.authUrl.value
const currentError = computed(() => isOpenAI.value ? openaiOAuth.error.value : claudeOAuth.error.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 // Computed
const isManualInputMethod = computed(() => { const isManualInputMethod = computed(() => {
// OpenAI always uses manual input (no cookie auth option) // 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 canExchangeCode = computed(() => {
const authCode = oauthFlowRef.value?.authCode || '' const authCode = oauthFlowRef.value?.authCode || ''
const sessionId = isOpenAI.value ? openaiOAuth.sessionId.value : claudeOAuth.sessionId.value const sessionId = isOpenAI.value
const loading = isOpenAI.value ? openaiOAuth.loading.value : claudeOAuth.loading.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 return authCode.trim() && sessionId && !loading
}) })
// Watchers // Watchers
watch(() => props.show, (newVal) => { watch(
if (newVal && props.account) { () => props.show,
// Initialize addMethod based on current account type (Claude only) (newVal) => {
if (!isOpenAI.value && (props.account.type === 'oauth' || props.account.type === 'setup-token')) { if (newVal && props.account) {
addMethod.value = props.account.type as AddMethod // Initialize addMethod based on current account type (Claude only)
if (
isAnthropic.value &&
(props.account.type === 'oauth' || props.account.type === 'setup-token')
) {
addMethod.value = props.account.type as AddMethod
}
if (isGemini.value) {
const creds = (props.account.credentials || {}) as Record<string, unknown>
geminiOAuthType.value = creds.oauth_type === '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()
} }
} else {
resetState()
} }
}) )
// Methods // Methods
const resetState = () => { const resetState = () => {
addMethod.value = 'oauth' addMethod.value = 'oauth'
geminiOAuthType.value = 'code_assist'
geminiAIStudioOAuthEnabled.value = false
claudeOAuth.resetState() claudeOAuth.resetState()
openaiOAuth.resetState() openaiOAuth.resetState()
geminiOAuth.resetState()
oauthFlowRef.value?.reset() 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 = () => { const handleClose = () => {
emit('close') emit('close')
} }
...@@ -195,6 +405,9 @@ const handleGenerateUrl = async () => { ...@@ -195,6 +405,9 @@ const handleGenerateUrl = async () => {
if (isOpenAI.value) { if (isOpenAI.value) {
await openaiOAuth.generateAuthUrl(props.account.proxy_id) 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 { } else {
await claudeOAuth.generateAuthUrl(addMethod.value, props.account.proxy_id) await claudeOAuth.generateAuthUrl(addMethod.value, props.account.proxy_id)
} }
...@@ -211,7 +424,11 @@ const handleExchangeCode = async () => { ...@@ -211,7 +424,11 @@ const handleExchangeCode = async () => {
const sessionId = openaiOAuth.sessionId.value const sessionId = openaiOAuth.sessionId.value
if (!sessionId) return 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 if (!tokenInfo) return
// Build credentials and extra info // Build credentials and extra info
...@@ -236,6 +453,38 @@ const handleExchangeCode = async () => { ...@@ -236,6 +453,38 @@ const handleExchangeCode = async () => {
openaiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed') openaiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(openaiOAuth.error.value) 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 { } else {
// Claude OAuth flow // Claude OAuth flow
const sessionId = claudeOAuth.sessionId.value const sessionId = claudeOAuth.sessionId.value
...@@ -246,9 +495,10 @@ const handleExchangeCode = async () => { ...@@ -246,9 +495,10 @@ const handleExchangeCode = async () => {
try { try {
const proxyConfig = props.account.proxy_id ? { proxy_id: props.account.proxy_id } : {} const proxyConfig = props.account.proxy_id ? { proxy_id: props.account.proxy_id } : {}
const endpoint = addMethod.value === 'oauth' const endpoint =
? '/admin/accounts/exchange-code' addMethod.value === 'oauth'
: '/admin/accounts/exchange-setup-token-code' ? '/admin/accounts/exchange-code'
: '/admin/accounts/exchange-setup-token-code'
const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, { const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, {
session_id: sessionId, session_id: sessionId,
...@@ -288,9 +538,10 @@ const handleCookieAuth = async (sessionKey: string) => { ...@@ -288,9 +538,10 @@ const handleCookieAuth = async (sessionKey: string) => {
try { try {
const proxyConfig = props.account.proxy_id ? { proxy_id: props.account.proxy_id } : {} const proxyConfig = props.account.proxy_id ? { proxy_id: props.account.proxy_id } : {}
const endpoint = addMethod.value === 'oauth' const endpoint =
? '/admin/accounts/cookie-auth' addMethod.value === 'oauth'
: '/admin/accounts/setup-token-cookie-auth' ? '/admin/accounts/cookie-auth'
: '/admin/accounts/setup-token-cookie-auth'
const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, { const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, {
session_id: '', session_id: '',
...@@ -314,7 +565,8 @@ const handleCookieAuth = async (sessionKey: string) => { ...@@ -314,7 +565,8 @@ const handleCookieAuth = async (sessionKey: string) => {
emit('reauthorized') emit('reauthorized')
handleClose() handleClose()
} catch (error: any) { } 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 { } finally {
claudeOAuth.loading.value = false claudeOAuth.loading.value = false
} }
......
...@@ -10,10 +10,15 @@ ...@@ -10,10 +10,15 @@
<div class="text-sm text-gray-600 dark:text-dark-300"> <div class="text-sm text-gray-600 dark:text-dark-300">
{{ t('admin.accounts.syncFromCrsDesc') }} {{ t('admin.accounts.syncFromCrsDesc') }}
</div> </div>
<div class="text-xs text-gray-500 dark:text-dark-400 bg-gray-50 dark:bg-dark-700/60 rounded-lg p-3"> <div
已有账号仅同步 CRS 返回的字段,缺失字段保持原值;凭据按键合并,不会清空未下发的键;未勾选"同步代理"时保留原有代理。 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>
<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') }} {{ t('admin.accounts.crsVersionRequirement') }}
</div> </div>
...@@ -31,12 +36,7 @@ ...@@ -31,12 +36,7 @@
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div> <div>
<label class="input-label">{{ t('admin.accounts.crsUsername') }}</label> <label class="input-label">{{ t('admin.accounts.crsUsername') }}</label>
<input <input v-model="form.username" type="text" class="input" autocomplete="username" />
v-model="form.username"
type="text"
class="input"
autocomplete="username"
/>
</div> </div>
<div> <div>
<label class="input-label">{{ t('admin.accounts.crsPassword') }}</label> <label class="input-label">{{ t('admin.accounts.crsPassword') }}</label>
...@@ -50,12 +50,19 @@ ...@@ -50,12 +50,19 @@
</div> </div>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-dark-300"> <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') }} {{ t('admin.accounts.syncProxies') }}
</label> </label>
</div> </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"> <div class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.accounts.syncResult') }} {{ t('admin.accounts.syncResult') }}
</div> </div>
...@@ -67,9 +74,12 @@ ...@@ -67,9 +74,12 @@
<div class="text-sm font-medium text-red-600 dark:text-red-400"> <div class="text-sm font-medium text-red-600 dark:text-red-400">
{{ t('admin.accounts.syncErrors') }} {{ t('admin.accounts.syncErrors') }}
</div> </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"> <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> </div>
</div> </div>
......
<template> <template>
<div> <div>
<!-- Window stats row (above progress bar, left-right aligned with progress bar) --> <!-- 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
<div class="flex items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400 cursor-help"> v-if="windowStats"
<span class="px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800"> 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 {{ formatRequests }} req
</span> </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 }} {{ formatTokens }}
</span> </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"> ${{ formatCost }} </span>
${{ formatCost }}
</span>
</div> </div>
</div> </div>
...@@ -19,16 +23,13 @@ ...@@ -19,16 +23,13 @@
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<!-- Label badge (fixed width for alignment) --> <!-- Label badge (fixed width for alignment) -->
<span <span
:class="[ :class="['w-[32px] shrink-0 rounded px-1 text-center text-[10px] font-medium', labelClass]"
'text-[10px] font-medium px-1 rounded w-[32px] text-center shrink-0',
labelClass
]"
> >
{{ label }} {{ label }}
</span> </span>
<!-- Progress bar container --> <!-- 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 <div
:class="['h-full transition-all duration-300', barClass]" :class="['h-full transition-all duration-300', barClass]"
:style="{ width: barWidth }" :style="{ width: barWidth }"
...@@ -36,12 +37,12 @@ ...@@ -36,12 +37,12 @@
</div> </div>
<!-- Percentage --> <!-- 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 }} {{ displayPercent }}
</span> </span>
<!-- Reset time --> <!-- 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 }} {{ formatResetTime }}
</span> </span>
</div> </div>
...@@ -54,7 +55,7 @@ import type { WindowStats } from '@/types' ...@@ -54,7 +55,7 @@ import type { WindowStats } from '@/types'
const props = defineProps<{ const props = defineProps<{
label: string label: string
utilization: number // Percentage (0-100+) utilization: number // Percentage (0-100+)
resetsAt?: string | null resetsAt?: string | null
color: 'indigo' | 'emerald' | 'purple' color: 'indigo' | 'emerald' | 'purple'
windowStats?: WindowStats | null windowStats?: WindowStats | null
......
<template> <template>
<div class="card p-4"> <div class="card p-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">{{ t('admin.dashboard.modelDistribution') }}</h3> <h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
<div v-if="loading" class="flex items-center justify-center h-48"> {{ t('admin.dashboard.modelDistribution') }}
</h3>
<div v-if="loading" class="flex h-48 items-center justify-center">
<LoadingSpinner /> <LoadingSpinner />
</div> </div>
<div v-else-if="modelStats.length > 0 && chartData" class="flex items-center gap-6"> <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" /> <Doughnut :data="chartData" :options="doughnutOptions" />
</div> </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"> <table class="w-full text-xs">
<thead> <thead>
<tr class="text-gray-500 dark:text-gray-400"> <tr class="text-gray-500 dark:text-gray-400">
<th class="text-left pb-2">{{ t('admin.dashboard.model') }}</th> <th class="pb-2 text-left">{{ t('admin.dashboard.model') }}</th>
<th class="text-right pb-2">{{ t('admin.dashboard.requests') }}</th> <th class="pb-2 text-right">{{ t('admin.dashboard.requests') }}</th>
<th class="text-right pb-2">{{ t('admin.dashboard.tokens') }}</th> <th class="pb-2 text-right">{{ t('admin.dashboard.tokens') }}</th>
<th class="text-right pb-2">{{ t('admin.dashboard.actual') }}</th> <th class="pb-2 text-right">{{ t('admin.dashboard.actual') }}</th>
<th class="text-right pb-2">{{ t('admin.dashboard.standard') }}</th> <th class="pb-2 text-right">{{ t('admin.dashboard.standard') }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="model in modelStats" :key="model.model" class="border-t border-gray-100 dark:border-gray-700"> <tr
<td class="py-1.5 text-gray-900 dark:text-white font-medium truncate max-w-[100px]" :title="model.model">{{ model.model }}</td> v-for="model in modelStats"
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">{{ formatNumber(model.requests) }}</td> :key="model.model"
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">{{ formatTokens(model.total_tokens) }}</td> class="border-t border-gray-100 dark:border-gray-700"
<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> <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> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</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') }} {{ t('admin.dashboard.noDataAvailable') }}
</div> </div>
</div> </div>
...@@ -40,12 +62,7 @@ ...@@ -40,12 +62,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js'
Chart as ChartJS,
ArcElement,
Tooltip,
Legend
} from 'chart.js'
import { Doughnut } from 'vue-chartjs' import { Doughnut } from 'vue-chartjs'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue' import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import type { ModelStat } from '@/types' import type { ModelStat } from '@/types'
...@@ -60,20 +77,30 @@ const props = defineProps<{ ...@@ -60,20 +77,30 @@ const props = defineProps<{
}>() }>()
const chartColors = [ const chartColors = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#3b82f6',
'#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16' '#10b981',
'#f59e0b',
'#ef4444',
'#8b5cf6',
'#ec4899',
'#14b8a6',
'#f97316',
'#6366f1',
'#84cc16'
] ]
const chartData = computed(() => { const chartData = computed(() => {
if (!props.modelStats?.length) return null if (!props.modelStats?.length) return null
return { return {
labels: props.modelStats.map(m => m.model), labels: props.modelStats.map((m) => m.model),
datasets: [{ datasets: [
data: props.modelStats.map(m => m.total_tokens), {
backgroundColor: chartColors.slice(0, props.modelStats.length), data: props.modelStats.map((m) => m.total_tokens),
borderWidth: 0, backgroundColor: chartColors.slice(0, props.modelStats.length),
}], borderWidth: 0
}
]
} }
}) })
...@@ -82,7 +109,7 @@ const doughnutOptions = computed(() => ({ ...@@ -82,7 +109,7 @@ const doughnutOptions = computed(() => ({
maintainAspectRatio: false, maintainAspectRatio: false,
plugins: { plugins: {
legend: { legend: {
display: false, display: false
}, },
tooltip: { tooltip: {
callbacks: { callbacks: {
...@@ -91,10 +118,10 @@ const doughnutOptions = computed(() => ({ ...@@ -91,10 +118,10 @@ const doughnutOptions = computed(() => ({
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0) const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
const percentage = ((value / total) * 100).toFixed(1) const percentage = ((value / total) * 100).toFixed(1)
return `${context.label}: ${formatTokens(value)} (${percentage}%)` return `${context.label}: ${formatTokens(value)} (${percentage}%)`
}, }
}, }
}, }
}, }
})) }))
const formatTokens = (value: number): string => { const formatTokens = (value: number): string => {
......
<template> <template>
<div class="card p-4"> <div class="card p-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">{{ t('admin.dashboard.tokenUsageTrend') }}</h3> <h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
<div v-if="loading" class="flex items-center justify-center h-48"> {{ t('admin.dashboard.tokenUsageTrend') }}
</h3>
<div v-if="loading" class="flex h-48 items-center justify-center">
<LoadingSpinner /> <LoadingSpinner />
</div> </div>
<div v-else-if="trendData.length > 0 && chartData" class="h-48"> <div v-else-if="trendData.length > 0 && chartData" class="h-48">
<Line :data="chartData" :options="lineOptions" /> <Line :data="chartData" :options="lineOptions" />
</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') }} {{ t('admin.dashboard.noDataAvailable') }}
</div> </div>
</div> </div>
...@@ -58,40 +63,40 @@ const chartColors = computed(() => ({ ...@@ -58,40 +63,40 @@ const chartColors = computed(() => ({
grid: isDarkMode.value ? '#374151' : '#e5e7eb', grid: isDarkMode.value ? '#374151' : '#e5e7eb',
input: '#3b82f6', input: '#3b82f6',
output: '#10b981', output: '#10b981',
cache: '#f59e0b', cache: '#f59e0b'
})) }))
const chartData = computed(() => { const chartData = computed(() => {
if (!props.trendData?.length) return null if (!props.trendData?.length) return null
return { return {
labels: props.trendData.map(d => d.date), labels: props.trendData.map((d) => d.date),
datasets: [ datasets: [
{ {
label: 'Input', label: 'Input',
data: props.trendData.map(d => d.input_tokens), data: props.trendData.map((d) => d.input_tokens),
borderColor: chartColors.value.input, borderColor: chartColors.value.input,
backgroundColor: `${chartColors.value.input}20`, backgroundColor: `${chartColors.value.input}20`,
fill: true, fill: true,
tension: 0.3, tension: 0.3
}, },
{ {
label: 'Output', label: 'Output',
data: props.trendData.map(d => d.output_tokens), data: props.trendData.map((d) => d.output_tokens),
borderColor: chartColors.value.output, borderColor: chartColors.value.output,
backgroundColor: `${chartColors.value.output}20`, backgroundColor: `${chartColors.value.output}20`,
fill: true, fill: true,
tension: 0.3, tension: 0.3
}, },
{ {
label: 'Cache', label: 'Cache',
data: props.trendData.map(d => d.cache_tokens), data: props.trendData.map((d) => d.cache_tokens),
borderColor: chartColors.value.cache, borderColor: chartColors.value.cache,
backgroundColor: `${chartColors.value.cache}20`, backgroundColor: `${chartColors.value.cache}20`,
fill: true, fill: true,
tension: 0.3, tension: 0.3
}, }
], ]
} }
}) })
...@@ -100,7 +105,7 @@ const lineOptions = computed(() => ({ ...@@ -100,7 +105,7 @@ const lineOptions = computed(() => ({
maintainAspectRatio: false, maintainAspectRatio: false,
interaction: { interaction: {
intersect: false, intersect: false,
mode: 'index' as const, mode: 'index' as const
}, },
plugins: { plugins: {
legend: { legend: {
...@@ -111,9 +116,9 @@ const lineOptions = computed(() => ({ ...@@ -111,9 +116,9 @@ const lineOptions = computed(() => ({
pointStyle: 'circle', pointStyle: 'circle',
padding: 15, padding: 15,
font: { font: {
size: 11, size: 11
}, }
}, }
}, },
tooltip: { tooltip: {
callbacks: { callbacks: {
...@@ -127,35 +132,35 @@ const lineOptions = computed(() => ({ ...@@ -127,35 +132,35 @@ const lineOptions = computed(() => ({
return `Actual: $${formatCost(data.actual_cost)} | Standard: $${formatCost(data.cost)}` return `Actual: $${formatCost(data.actual_cost)} | Standard: $${formatCost(data.cost)}`
} }
return '' return ''
}, }
}, }
}, }
}, },
scales: { scales: {
x: { x: {
grid: { grid: {
color: chartColors.value.grid, color: chartColors.value.grid
}, },
ticks: { ticks: {
color: chartColors.value.text, color: chartColors.value.text,
font: { font: {
size: 10, size: 10
}, }
}, }
}, },
y: { y: {
grid: { grid: {
color: chartColors.value.grid, color: chartColors.value.grid
}, },
ticks: { ticks: {
color: chartColors.value.text, color: chartColors.value.text,
font: { font: {
size: 10, size: 10
}, },
callback: (value: string | number) => formatTokens(Number(value)), callback: (value: string | number) => formatTokens(Number(value))
}, }
}, }
}, }
})) }))
const formatTokens = (value: number): string => { const formatTokens = (value: number): string => {
......
...@@ -9,7 +9,7 @@ ...@@ -9,7 +9,7 @@
<button <button
@click="handleCancel" @click="handleCancel"
type="button" 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 }} {{ cancelText }}
</button> </button>
...@@ -17,7 +17,7 @@ ...@@ -17,7 +17,7 @@
@click="handleConfirm" @click="handleConfirm"
type="button" type="button"
:class="[ :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 danger
? 'bg-red-600 hover:bg-red-700 focus:ring-red-500' ? 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
: 'bg-primary-600 hover:bg-primary-700 focus:ring-primary-500' : 'bg-primary-600 hover:bg-primary-700 focus:ring-primary-500'
......
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
v-for="column in columns" v-for="column in columns"
:key="column.key" :key="column.key"
scope="col" 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 }" :class="{ 'cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-700': column.sortable }"
@click="column.sortable && handleSort(column.key)" @click="column.sortable && handleSort(column.key)"
> >
...@@ -16,8 +16,8 @@ ...@@ -16,8 +16,8 @@
<span v-if="column.sortable" class="text-gray-400 dark:text-dark-500"> <span v-if="column.sortable" class="text-gray-400 dark:text-dark-500">
<svg <svg
v-if="sortKey === column.key" v-if="sortKey === column.key"
class="w-4 h-4" class="h-4 w-4"
:class="{ 'transform rotate-180': sortOrder === 'desc' }" :class="{ 'rotate-180 transform': sortOrder === 'desc' }"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
> >
...@@ -27,7 +27,7 @@ ...@@ -27,7 +27,7 @@
clip-rule="evenodd" clip-rule="evenodd"
/> />
</svg> </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 <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" 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 @@ ...@@ -37,22 +37,30 @@
</th> </th>
</tr> </tr>
</thead> </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 --> <!-- Loading skeleton -->
<tr v-if="loading" v-for="i in 5" :key="i"> <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="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> </div>
</td> </td>
</tr> </tr>
<!-- Empty state --> <!-- Empty state -->
<tr v-else-if="!data || data.length === 0"> <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"> <slot name="empty">
<div class="flex flex-col items-center"> <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 <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
...@@ -60,18 +68,25 @@ ...@@ -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" 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> </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> </div>
</slot> </slot>
</td> </td>
</tr> </tr>
<!-- Data rows --> <!-- 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 <td
v-for="column in columns" v-for="column in columns"
:key="column.key" :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]"> <slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]">
{{ column.formatter ? column.formatter(row[column.key], row) : row[column.key] }} {{ column.formatter ? column.formatter(row[column.key], row) : row[column.key] }}
......
...@@ -3,14 +3,21 @@ ...@@ -3,14 +3,21 @@
<button <button
type="button" type="button"
@click="toggle" @click="toggle"
:class="[ :class="['date-picker-trigger', isOpen && 'date-picker-trigger-open']"
'date-picker-trigger',
isOpen && 'date-picker-trigger-open'
]"
> >
<span class="date-picker-icon"> <span class="date-picker-icon">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> <svg
<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" /> 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> </svg>
</span> </span>
<span class="date-picker-value"> <span class="date-picker-value">
...@@ -18,7 +25,7 @@ ...@@ -18,7 +25,7 @@
</span> </span>
<span class="date-picker-chevron"> <span class="date-picker-chevron">
<svg <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" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
...@@ -30,20 +37,14 @@ ...@@ -30,20 +37,14 @@
</button> </button>
<Transition name="date-picker-dropdown"> <Transition name="date-picker-dropdown">
<div <div v-if="isOpen" class="date-picker-dropdown">
v-if="isOpen"
class="date-picker-dropdown"
>
<!-- Quick presets --> <!-- Quick presets -->
<div class="date-picker-presets"> <div class="date-picker-presets">
<button <button
v-for="preset in presets" v-for="preset in presets"
:key="preset.value" :key="preset.value"
@click="selectPreset(preset)" @click="selectPreset(preset)"
:class="[ :class="['date-picker-preset', isPresetActive(preset) && 'date-picker-preset-active']"
'date-picker-preset',
isPresetActive(preset) && 'date-picker-preset-active'
]"
> >
{{ t(preset.labelKey) }} {{ t(preset.labelKey) }}
</button> </button>
...@@ -64,8 +65,18 @@ ...@@ -64,8 +65,18 @@
/> />
</div> </div>
<div class="date-picker-separator"> <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"> <svg
<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 8.25L21 12m0 0l-3.75 3.75M21 12H3" /> 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> </svg>
</div> </div>
<div class="date-picker-field"> <div class="date-picker-field">
...@@ -83,10 +94,7 @@ ...@@ -83,10 +94,7 @@
<!-- Apply button --> <!-- Apply button -->
<div class="date-picker-actions"> <div class="date-picker-actions">
<button <button @click="apply" class="date-picker-apply">
@click="apply"
class="date-picker-apply"
>
{{ t('dates.apply') }} {{ t('dates.apply') }}
</button> </button>
</div> </div>
...@@ -204,7 +212,7 @@ const presets: DatePreset[] = [ ...@@ -204,7 +212,7 @@ const presets: DatePreset[] = [
const displayValue = computed(() => { const displayValue = computed(() => {
if (activePreset.value) { 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) if (preset) return t(preset.labelKey)
} }
...@@ -275,15 +283,21 @@ const handleEscape = (event: KeyboardEvent) => { ...@@ -275,15 +283,21 @@ const handleEscape = (event: KeyboardEvent) => {
} }
// Sync local state with props // Sync local state with props
watch(() => props.startDate, (val) => { watch(
localStartDate.value = val () => props.startDate,
onDateChange() (val) => {
}) localStartDate.value = val
onDateChange()
}
)
watch(() => props.endDate, (val) => { watch(
localEndDate.value = val () => props.endDate,
onDateChange() (val) => {
}) localEndDate.value = val
onDateChange()
}
)
onMounted(() => { onMounted(() => {
document.addEventListener('click', handleClickOutside) document.addEventListener('click', handleClickOutside)
...@@ -301,18 +315,18 @@ onUnmounted(() => { ...@@ -301,18 +315,18 @@ onUnmounted(() => {
<style scoped> <style scoped>
.date-picker-trigger { .date-picker-trigger {
@apply flex items-center gap-2; @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 bg-white dark:bg-dark-800;
@apply border border-gray-200 dark:border-dark-600; @apply border border-gray-200 dark:border-dark-600;
@apply text-gray-700 dark:text-gray-300; @apply text-gray-700 dark:text-gray-300;
@apply transition-all duration-200; @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 hover:border-gray-300 dark:hover:border-dark-500;
@apply cursor-pointer; @apply cursor-pointer;
} }
.date-picker-trigger-open { .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 { .date-picker-icon {
...@@ -328,7 +342,7 @@ onUnmounted(() => { ...@@ -328,7 +342,7 @@ onUnmounted(() => {
} }
.date-picker-dropdown { .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 bg-white dark:bg-dark-800;
@apply rounded-xl; @apply rounded-xl;
@apply border border-gray-200 dark:border-dark-700; @apply border border-gray-200 dark:border-dark-700;
...@@ -342,7 +356,7 @@ onUnmounted(() => { ...@@ -342,7 +356,7 @@ onUnmounted(() => {
} }
.date-picker-preset { .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 text-gray-600 dark:text-gray-400;
@apply hover:bg-gray-100 dark:hover:bg-dark-700; @apply hover:bg-gray-100 dark:hover:bg-dark-700;
@apply transition-colors duration-150; @apply transition-colors duration-150;
...@@ -366,15 +380,15 @@ onUnmounted(() => { ...@@ -366,15 +380,15 @@ onUnmounted(() => {
} }
.date-picker-label { .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 { .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 bg-gray-50 dark:bg-dark-700;
@apply border border-gray-200 dark:border-dark-600; @apply border border-gray-200 dark:border-dark-600;
@apply text-gray-900 dark:text-gray-100; @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 { .date-picker-input::-webkit-calendar-picker-indicator {
...@@ -395,7 +409,7 @@ onUnmounted(() => { ...@@ -395,7 +409,7 @@ onUnmounted(() => {
} }
.date-picker-apply { .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 bg-primary-600 text-white;
@apply hover:bg-primary-700; @apply hover:bg-primary-700;
@apply transition-colors duration-150; @apply transition-colors duration-150;
......
<template> <template>
<div class="empty-state"> <div class="empty-state">
<!-- Icon --> <!-- 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"> <slot name="icon">
<component <component v-if="icon" :is="icon" class="empty-state-icon h-10 w-10" aria-hidden="true" />
v-if="icon"
:is="icon"
class="empty-state-icon w-10 h-10"
aria-hidden="true"
/>
<svg <svg
v-else v-else
class="empty-state-icon w-10 h-10" class="empty-state-icon h-10 w-10"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
...@@ -48,17 +45,13 @@ ...@@ -48,17 +45,13 @@
> >
<svg <svg
v-if="actionIcon" v-if="actionIcon"
class="w-5 h-5 mr-2" class="mr-2 h-5 w-5"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke-width="1.5" stroke-width="1.5"
> >
<path <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
stroke-linecap="round"
stroke-linejoin="round"
d="M12 4.5v15m7.5-7.5h-15"
/>
</svg> </svg>
{{ actionText }} {{ actionText }}
</component> </component>
......
<template> <template>
<span <span
:class="[ :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 badgeClass
]" ]"
> >
...@@ -10,10 +10,7 @@ ...@@ -10,10 +10,7 @@
<!-- Group name --> <!-- Group name -->
<span class="truncate">{{ name }}</span> <span class="truncate">{{ name }}</span>
<!-- Right side label --> <!-- Right side label -->
<span <span v-if="showLabel" :class="labelClass">
v-if="showLabel"
:class="labelClass"
>
{{ labelText }} {{ labelText }}
</span> </span>
</span> </span>
...@@ -31,7 +28,7 @@ interface Props { ...@@ -31,7 +28,7 @@ interface Props {
subscriptionType?: SubscriptionType subscriptionType?: SubscriptionType
rateMultiplier?: number rateMultiplier?: number
showRate?: boolean showRate?: boolean
daysRemaining?: number | null // 剩余天数(订阅类型时使用) daysRemaining?: number | null // 剩余天数(订阅类型时使用)
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
...@@ -97,6 +94,9 @@ const labelClass = computed(() => { ...@@ -97,6 +94,9 @@ const labelClass = computed(() => {
if (props.platform === 'openai') { if (props.platform === 'openai') {
return `${base} bg-emerald-200/60 text-emerald-800 dark:bg-emerald-800/40 dark:text-emerald-300` 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` 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(() => { ...@@ -113,6 +113,11 @@ const badgeClass = computed(() => {
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' ? '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' : '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 // Fallback: original colors
return isSubscription.value return isSubscription.value
? 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400' ? 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400'
......
...@@ -2,15 +2,15 @@ ...@@ -2,15 +2,15 @@
<div> <div>
<label class="input-label"> <label class="input-label">
Groups Groups
<span class="text-gray-400 font-normal">({{ modelValue.length }} selected)</span> <span class="font-normal text-gray-400">({{ modelValue.length }} selected)</span>
</label> </label>
<div <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 <label
v-for="group in filteredGroups" v-for="group in filteredGroups"
:key="group.id" :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`" :title="`${group.rate_multiplier}x rate · ${group.account_count || 0} accounts`"
> >
<input <input
...@@ -18,19 +18,19 @@ ...@@ -18,19 +18,19 @@
:value="group.id" :value="group.id"
:checked="modelValue.includes(group.id)" :checked="modelValue.includes(group.id)"
@change="handleChange(group.id, ($event.target as HTMLInputElement).checked)" @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 <GroupBadge
:name="group.name" :name="group.name"
:subscription-type="group.subscription_type" :subscription-type="group.subscription_type"
:rate-multiplier="group.rate_multiplier" :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> </label>
<div <div
v-if="filteredGroups.length === 0" 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 No groups available
</div> </div>
...@@ -59,13 +59,13 @@ const filteredGroups = computed(() => { ...@@ -59,13 +59,13 @@ const filteredGroups = computed(() => {
if (!props.platform) { if (!props.platform) {
return props.groups 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 handleChange = (groupId: number, checked: boolean) => {
const newValue = checked const newValue = checked
? [...props.modelValue, groupId] ? [...props.modelValue, groupId]
: props.modelValue.filter(id => id !== groupId) : props.modelValue.filter((id) => id !== groupId)
emit('update:modelValue', newValue) emit('update:modelValue', newValue)
} }
</script> </script>
...@@ -2,13 +2,13 @@ ...@@ -2,13 +2,13 @@
<div class="relative" ref="dropdownRef"> <div class="relative" ref="dropdownRef">
<button <button
@click="toggleDropdown" @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" :title="currentLocale?.name"
> >
<span class="text-base">{{ currentLocale?.flag }}</span> <span class="text-base">{{ currentLocale?.flag }}</span>
<span class="hidden sm:inline">{{ currentLocale?.code.toUpperCase() }}</span> <span class="hidden sm:inline">{{ currentLocale?.code.toUpperCase() }}</span>
<svg <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 }" :class="{ 'rotate-180': isOpen }"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
...@@ -22,20 +22,23 @@ ...@@ -22,20 +22,23 @@
<transition name="dropdown"> <transition name="dropdown">
<div <div
v-if="isOpen" 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 <button
v-for="locale in availableLocales" v-for="locale in availableLocales"
:key="locale.code" :key="locale.code"
@click="selectLocale(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="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 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400': locale.code === currentLocaleCode }" :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 class="text-base">{{ locale.flag }}</span>
<span>{{ locale.name }}</span> <span>{{ locale.name }}</span>
<svg <svg
v-if="locale.code === currentLocaleCode" 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" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
...@@ -60,7 +63,7 @@ const isOpen = ref(false) ...@@ -60,7 +63,7 @@ const isOpen = ref(false)
const dropdownRef = ref<HTMLElement | null>(null) const dropdownRef = ref<HTMLElement | null>(null)
const currentLocaleCode = computed(() => locale.value) 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() { function toggleDropdown() {
isOpen.value = !isOpen.value isOpen.value = !isOpen.value
......
...@@ -9,24 +9,24 @@ ...@@ -9,24 +9,24 @@
@click.self="handleClose" @click.self="handleClose"
> >
<!-- Modal panel --> <!-- Modal panel -->
<div <div :class="['modal-content', sizeClasses]" @click.stop>
:class="['modal-content', sizeClasses]"
@click.stop
>
<!-- Header --> <!-- Header -->
<div class="modal-header"> <div class="modal-header">
<h3 <h3 id="modal-title" class="modal-title">
id="modal-title"
class="modal-title"
>
{{ title }} {{ title }}
</h3> </h3>
<button <button
@click="emit('close')" @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" 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" /> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
...@@ -38,10 +38,7 @@ ...@@ -38,10 +38,7 @@
</div> </div>
<!-- Footer --> <!-- Footer -->
<div <div v-if="$slots.footer" class="modal-footer">
v-if="$slots.footer"
class="modal-footer"
>
<slot name="footer"></slot> <slot name="footer"></slot>
</div> </div>
</div> </div>
......
<template> <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
<div class="flex items-center justify-between flex-1 sm:hidden"> 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 --> <!-- Mobile pagination -->
<button <button
@click="goToPage(page - 1)" @click="goToPage(page - 1)"
:disabled="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') }} {{ t('pagination.previous') }}
</button> </button>
...@@ -15,7 +17,7 @@ ...@@ -15,7 +17,7 @@
<button <button
@click="goToPage(page + 1)" @click="goToPage(page + 1)"
:disabled="page === totalPages" :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') }} {{ t('pagination.next') }}
</button> </button>
...@@ -36,8 +38,10 @@ ...@@ -36,8 +38,10 @@
<!-- Page size selector --> <!-- Page size selector -->
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('pagination.perPage') }}:</span> <span class="text-sm text-gray-700 dark:text-gray-300"
<div class="w-20 page-size-select"> >{{ t('pagination.perPage') }}:</span
>
<div class="page-size-select w-20">
<Select <Select
:model-value="pageSize" :model-value="pageSize"
:options="pageSizeSelectOptions" :options="pageSizeSelectOptions"
...@@ -56,10 +60,10 @@ ...@@ -56,10 +60,10 @@
<button <button
@click="goToPage(page - 1)" @click="goToPage(page - 1)"
:disabled="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')" :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 <path
fill-rule="evenodd" 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" 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 @@ ...@@ -75,13 +79,15 @@
@click="typeof pageNum === 'number' && goToPage(pageNum)" @click="typeof pageNum === 'number' && goToPage(pageNum)"
:disabled="typeof pageNum !== 'number'" :disabled="typeof pageNum !== 'number'"
:class="[ :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 pageNum === page
? 'z-10 bg-primary-50 dark:bg-primary-900/30 border-primary-500 text-primary-600 dark:text-primary-400' ? 'z-10 border-primary-500 bg-primary-50 text-primary-600 dark:bg-primary-900/30 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', : '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' 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" :aria-current="pageNum === page ? 'page' : undefined"
> >
{{ pageNum }} {{ pageNum }}
...@@ -91,10 +97,10 @@ ...@@ -91,10 +97,10 @@
<button <button
@click="goToPage(page + 1)" @click="goToPage(page + 1)"
:disabled="page === totalPages" :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')" :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 <path
fill-rule="evenodd" 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" 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(() => { ...@@ -145,7 +151,7 @@ const toItem = computed(() => {
}) })
const pageSizeSelectOptions = computed(() => { const pageSizeSelectOptions = computed(() => {
return props.pageSizeOptions.map(size => ({ return props.pageSizeOptions.map((size) => ({
value: size, value: size,
label: String(size) label: String(size)
})) }))
...@@ -209,6 +215,6 @@ const handlePageSizeChange = (value: string | number | null) => { ...@@ -209,6 +215,6 @@ const handlePageSizeChange = (value: string | number | null) => {
<style scoped> <style scoped>
.page-size-select :deep(.select-trigger) { .page-size-select :deep(.select-trigger) {
@apply py-1.5 px-3 text-sm; @apply px-3 py-1.5 text-sm;
} }
</style> </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