First commit

This commit is contained in:
shaw
2025-12-18 13:50:39 +08:00
parent 569f4882e5
commit 642842c29e
218 changed files with 86902 additions and 0 deletions

View File

@@ -0,0 +1,120 @@
<template>
<div class="flex items-center gap-2">
<!-- Main Status Badge -->
<span
:class="[
'badge text-xs',
statusClass
]"
>
{{ statusText }}
</span>
<!-- Error Info Indicator -->
<div v-if="hasError && account.error_message" class="relative group/error">
<svg class="w-4 h-4 text-red-500 dark:text-red-400 cursor-help hover:text-red-600 dark:hover:text-red-300 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<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>
<!-- Tooltip - 向下显示 -->
<div class="absolute top-full left-0 mt-1.5 px-3 py-2 bg-gray-800 dark:bg-gray-900 text-white text-xs rounded-lg shadow-xl opacity-0 invisible group-hover/error:opacity-100 group-hover/error:visible transition-all duration-200 z-[100] min-w-[200px] max-w-[300px]">
<div class="text-gray-300 break-words whitespace-pre-wrap leading-relaxed">{{ account.error_message }}</div>
<!-- 上方小三角 -->
<div class="absolute bottom-full left-3 border-[6px] border-transparent border-b-gray-800 dark:border-b-gray-900"></div>
</div>
</div>
<!-- Rate Limit Indicator (429) -->
<div v-if="isRateLimited" class="relative group">
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
429
</span>
<!-- Tooltip -->
<div class="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-50">
Rate limited until {{ formatTime(account.rate_limit_reset_at) }}
<div class="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"></div>
</div>
</div>
<!-- Overload Indicator (529) -->
<div v-if="isOverloaded" class="relative group">
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
529
</span>
<!-- Tooltip -->
<div class="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-50">
Overloaded until {{ formatTime(account.overload_until) }}
<div class="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Account } from '@/types'
const props = defineProps<{
account: Account
}>()
// Computed: is rate limited (429)
const isRateLimited = computed(() => {
if (!props.account.rate_limit_reset_at) return false
return new Date(props.account.rate_limit_reset_at) > new Date()
})
// Computed: is overloaded (529)
const isOverloaded = computed(() => {
if (!props.account.overload_until) return false
return new Date(props.account.overload_until) > new Date()
})
// Computed: has error status
const hasError = computed(() => {
return props.account.status === 'error'
})
// Computed: status badge class
const statusClass = computed(() => {
if (!props.account.schedulable || isRateLimited.value || isOverloaded.value) {
return 'badge-gray'
}
switch (props.account.status) {
case 'active':
return 'badge-success'
case 'inactive':
return 'badge-gray'
case 'error':
return 'badge-danger'
default:
return 'badge-gray'
}
})
// Computed: status text
const statusText = computed(() => {
if (!props.account.schedulable) {
return 'Paused'
}
if (isRateLimited.value || isOverloaded.value) {
return 'Limited'
}
return props.account.status
})
// Format time helper
const formatTime = (dateStr: string | null | undefined) => {
if (!dateStr) return 'N/A'
const date = new Date(dateStr)
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
})
}
</script>

View File

@@ -0,0 +1,342 @@
<template>
<Modal
:show="show"
:title="t('admin.accounts.testAccountConnection')"
size="md"
@close="handleClose"
>
<div class="space-y-4">
<!-- Account Info Card -->
<div v-if="account" class="flex items-center justify-between p-3 bg-gradient-to-r from-gray-50 to-gray-100 dark:from-dark-700 dark:to-dark-600 rounded-xl border border-gray-200 dark:border-dark-500">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center">
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<div class="font-semibold text-gray-900 dark:text-gray-100">{{ account.name }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1.5">
<span class="px-1.5 py-0.5 bg-gray-200 dark:bg-dark-500 rounded text-[10px] font-medium uppercase">
{{ account.type }}
</span>
<span>{{ t('admin.accounts.account') }}</span>
</div>
</div>
</div>
<span
:class="[
'px-2.5 py-1 text-xs font-semibold rounded-full',
account.status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
]"
>
{{ account.status }}
</span>
</div>
<!-- Terminal Output -->
<div class="relative group">
<div
ref="terminalRef"
class="bg-gray-900 dark:bg-black rounded-xl p-4 min-h-[120px] max-h-[240px] overflow-y-auto font-mono text-sm border border-gray-700 dark:border-gray-800"
>
<!-- Status Line -->
<div v-if="status === 'idle'" class="text-gray-500 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span>{{ t('admin.accounts.readyToTest') }}</span>
</div>
<div v-else-if="status === 'connecting'" class="text-yellow-400 flex items-center gap-2">
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>{{ t('admin.accounts.connectingToApi') }}</span>
</div>
<!-- Output Lines -->
<div v-for="(line, index) in outputLines" :key="index" :class="line.class">
{{ line.text }}
</div>
<!-- Streaming Content -->
<div v-if="streamingContent" class="text-green-400">
{{ streamingContent }}<span class="animate-pulse">_</span>
</div>
<!-- Result Status -->
<div v-if="status === 'success'" class="text-green-400 mt-3 pt-3 border-t border-gray-700 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{{ t('admin.accounts.testCompleted') }}</span>
</div>
<div v-else-if="status === 'error'" class="text-red-400 mt-3 pt-3 border-t border-gray-700 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{{ errorMessage }}</span>
</div>
</div>
<!-- Copy Button -->
<button
v-if="outputLines.length > 0"
@click="copyOutput"
class="absolute top-2 right-2 p-1.5 text-gray-400 hover:text-white bg-gray-800/80 hover:bg-gray-700 rounded-lg transition-all opacity-0 group-hover:opacity-100"
:title="t('admin.accounts.copyOutput')"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
</div>
<!-- Test Info -->
<div class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 px-1">
<div class="flex items-center gap-3">
<span class="flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
{{ t('admin.accounts.testModel') }}
</span>
</div>
<span class="flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
{{ t('admin.accounts.testPrompt') }}
</span>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<button
@click="handleClose"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-dark-600 hover:bg-gray-200 dark:hover:bg-dark-500 rounded-lg transition-colors"
:disabled="status === 'connecting'"
>
{{ t('common.close') }}
</button>
<button
@click="startTest"
:disabled="status === 'connecting'"
:class="[
'px-4 py-2 text-sm font-medium rounded-lg transition-all flex items-center gap-2',
status === 'connecting'
? 'bg-primary-400 text-white cursor-not-allowed'
: status === 'success'
? 'bg-green-500 hover:bg-green-600 text-white'
: status === 'error'
? 'bg-orange-500 hover:bg-orange-600 text-white'
: 'bg-primary-500 hover:bg-primary-600 text-white'
]"
>
<svg v-if="status === 'connecting'" class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<svg v-else-if="status === 'idle'" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg v-else class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>
{{ status === 'connecting' ? t('admin.accounts.testing') : status === 'idle' ? t('admin.accounts.startTest') : t('admin.accounts.retry') }}
</span>
</button>
</div>
</template>
</Modal>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import Modal from '@/components/common/Modal.vue'
import type { Account } from '@/types'
const { t } = useI18n()
interface OutputLine {
text: string
class: string
}
const props = defineProps<{
show: boolean
account: Account | null
}>()
const emit = defineEmits<{
(e: 'close'): void
}>()
const terminalRef = ref<HTMLElement | null>(null)
const status = ref<'idle' | 'connecting' | 'success' | 'error'>('idle')
const outputLines = ref<OutputLine[]>([])
const streamingContent = ref('')
const errorMessage = ref('')
let eventSource: EventSource | null = null
// Reset state when modal opens
watch(() => props.show, (newVal) => {
if (newVal) {
resetState()
} else {
closeEventSource()
}
})
const resetState = () => {
status.value = 'idle'
outputLines.value = []
streamingContent.value = ''
errorMessage.value = ''
}
const handleClose = () => {
closeEventSource()
emit('close')
}
const closeEventSource = () => {
if (eventSource) {
eventSource.close()
eventSource = null
}
}
const addLine = (text: string, className: string = 'text-gray-300') => {
outputLines.value.push({ text, class: className })
scrollToBottom()
}
const scrollToBottom = async () => {
await nextTick()
if (terminalRef.value) {
terminalRef.value.scrollTop = terminalRef.value.scrollHeight
}
}
const startTest = async () => {
if (!props.account) return
resetState()
status.value = 'connecting'
addLine(t('admin.accounts.startingTestForAccount', { name: props.account.name }), 'text-blue-400')
addLine(t('admin.accounts.testAccountTypeLabel', { type: props.account.type }), 'text-gray-400')
addLine('', 'text-gray-300')
closeEventSource()
try {
// Create EventSource for SSE
const url = `/api/v1/admin/accounts/${props.account.id}/test`
// Use fetch with streaming for SSE since EventSource doesn't support POST
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`,
'Content-Type': 'application/json'
}
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const reader = response.body?.getReader()
if (!reader) {
throw new Error('No response body')
}
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const jsonStr = line.slice(6).trim()
if (jsonStr) {
try {
const event = JSON.parse(jsonStr)
handleEvent(event)
} catch (e) {
console.error('Failed to parse SSE event:', e)
}
}
}
}
}
} catch (error: any) {
status.value = 'error'
errorMessage.value = error.message || 'Unknown error'
addLine(`Error: ${errorMessage.value}`, 'text-red-400')
}
}
const handleEvent = (event: { type: string; text?: string; model?: string; success?: boolean; error?: string }) => {
switch (event.type) {
case 'test_start':
addLine(t('admin.accounts.connectedToApi'), 'text-green-400')
if (event.model) {
addLine(t('admin.accounts.usingModel', { model: event.model }), 'text-cyan-400')
}
addLine(t('admin.accounts.sendingTestMessage'), 'text-gray-400')
addLine('', 'text-gray-300')
addLine(t('admin.accounts.response'), 'text-yellow-400')
break
case 'content':
if (event.text) {
streamingContent.value += event.text
scrollToBottom()
}
break
case 'test_complete':
// Move streaming content to output lines
if (streamingContent.value) {
addLine(streamingContent.value, 'text-green-300')
streamingContent.value = ''
}
if (event.success) {
status.value = 'success'
} else {
status.value = 'error'
errorMessage.value = event.error || 'Test failed'
}
break
case 'error':
status.value = 'error'
errorMessage.value = event.error || 'Unknown error'
if (streamingContent.value) {
addLine(streamingContent.value, 'text-green-300')
streamingContent.value = ''
}
break
}
}
const copyOutput = () => {
const text = outputLines.value.map(l => l.text).join('\n')
navigator.clipboard.writeText(text)
}
</script>

View File

@@ -0,0 +1,82 @@
<template>
<div>
<!-- Loading state -->
<div v-if="loading" class="space-y-0.5">
<div class="h-3 w-12 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
<div class="h-3 w-16 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
<div class="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
</div>
<!-- Error state -->
<div v-else-if="error" class="text-xs text-red-500">
{{ error }}
</div>
<!-- Stats data -->
<div v-else-if="stats" class="space-y-0.5 text-xs">
<!-- Requests -->
<div class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400">Req:</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ formatNumber(stats.requests) }}</span>
</div>
<!-- Tokens -->
<div class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400">Tok:</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ formatTokens(stats.tokens) }}</span>
</div>
<!-- Cost -->
<div class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400">Cost:</span>
<span class="font-medium text-emerald-600 dark:text-emerald-400">{{ formatCurrency(stats.cost) }}</span>
</div>
</div>
<!-- No data -->
<div v-else class="text-xs text-gray-400">
-
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { adminAPI } from '@/api/admin'
import type { Account, WindowStats } from '@/types'
import { formatNumber, formatCurrency } from '@/utils/format'
const props = defineProps<{
account: Account
}>()
const loading = ref(false)
const error = ref<string | null>(null)
const stats = ref<WindowStats | null>(null)
// Format large token numbers (e.g., 1234567 -> 1.23M)
const formatTokens = (tokens: number): string => {
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(2)}M`
} else if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(1)}K`
}
return tokens.toString()
}
const loadStats = async () => {
loading.value = true
error.value = null
try {
stats.value = await adminAPI.accounts.getTodayStats(props.account.id)
} catch (e: any) {
error.value = 'Failed'
console.error('Failed to load today stats:', e)
} finally {
loading.value = false
}
}
onMounted(() => {
loadStats()
})
</script>

View File

@@ -0,0 +1,113 @@
<template>
<div v-if="account.type === 'oauth' || account.type === 'setup-token'">
<!-- OAuth accounts: fetch real usage data -->
<template v-if="account.type === 'oauth'">
<!-- Loading state -->
<div v-if="loading" class="space-y-1.5">
<div class="flex items-center gap-1">
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse"></div>
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
</div>
<div class="flex items-center gap-1">
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse"></div>
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
</div>
<div class="flex items-center gap-1">
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse"></div>
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
</div>
</div>
<!-- Error state -->
<div v-else-if="error" class="text-xs text-red-500">
{{ error }}
</div>
<!-- Usage data -->
<div v-else-if="usageInfo" class="space-y-1">
<!-- 5h Window -->
<UsageProgressBar
v-if="usageInfo.five_hour"
label="5h"
:utilization="usageInfo.five_hour.utilization"
:resets-at="usageInfo.five_hour.resets_at"
:window-stats="usageInfo.five_hour.window_stats"
color="indigo"
/>
<!-- 7d Window -->
<UsageProgressBar
v-if="usageInfo.seven_day"
label="7d"
:utilization="usageInfo.seven_day.utilization"
:resets-at="usageInfo.seven_day.resets_at"
color="emerald"
/>
<!-- 7d Sonnet Window -->
<UsageProgressBar
v-if="usageInfo.seven_day_sonnet"
label="7d S"
:utilization="usageInfo.seven_day_sonnet.utilization"
:resets-at="usageInfo.seven_day_sonnet.resets_at"
color="purple"
/>
</div>
<!-- No data yet -->
<div v-else class="text-xs text-gray-400">
-
</div>
</template>
<!-- Setup Token accounts: show time-based window progress -->
<template v-else-if="account.type === 'setup-token'">
<SetupTokenTimeWindow :account="account" />
</template>
</div>
<!-- Non-OAuth accounts -->
<div v-else class="text-xs text-gray-400">
-
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { adminAPI } from '@/api/admin'
import type { Account, AccountUsageInfo } from '@/types'
import UsageProgressBar from './UsageProgressBar.vue'
import SetupTokenTimeWindow from './SetupTokenTimeWindow.vue'
const props = defineProps<{
account: Account
}>()
const loading = ref(false)
const error = ref<string | null>(null)
const usageInfo = ref<AccountUsageInfo | null>(null)
const loadUsage = async () => {
// Only fetch usage for OAuth accounts (Setup Token uses local time-based calculation)
if (props.account.type !== 'oauth') return
loading.value = true
error.value = null
try {
usageInfo.value = await adminAPI.accounts.getUsage(props.account.id)
} catch (e: any) {
error.value = 'Failed'
console.error('Failed to load usage:', e)
} finally {
loading.value = false
}
}
onMounted(() => {
loadUsage()
})
</script>

View File

@@ -0,0 +1,929 @@
<template>
<Modal
:show="show"
:title="t('admin.accounts.createAccount')"
size="lg"
@close="handleClose"
>
<!-- Step Indicator for OAuth accounts -->
<div v-if="isOAuthFlow" class="mb-6 flex items-center justify-center">
<div class="flex items-center space-x-4">
<div class="flex items-center">
<div
:class="[
'flex h-8 w-8 items-center justify-center rounded-full text-sm font-semibold',
step >= 1 ? 'bg-primary-500 text-white' : 'bg-gray-200 text-gray-500 dark:bg-dark-600'
]"
>
1
</div>
<span class="ml-2 text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.accounts.oauth.authMethod') }}</span>
</div>
<div class="h-0.5 w-8 bg-gray-300 dark:bg-dark-600" />
<div class="flex items-center">
<div
:class="[
'flex h-8 w-8 items-center justify-center rounded-full text-sm font-semibold',
step >= 2 ? 'bg-primary-500 text-white' : 'bg-gray-200 text-gray-500 dark:bg-dark-600'
]"
>
2
</div>
<span class="ml-2 text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.accounts.oauth.title') }}</span>
</div>
</div>
</div>
<!-- Step 1: Basic Info -->
<form v-if="step === 1" @submit.prevent="handleSubmit" class="space-y-5">
<div>
<label class="input-label">{{ t('admin.accounts.accountName') }}</label>
<input
v-model="form.name"
type="text"
required
class="input"
:placeholder="t('admin.accounts.enterAccountName')"
/>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.accountType') }}</label>
<div class="grid grid-cols-2 gap-3 mt-2">
<label
:class="[
'relative flex cursor-pointer rounded-lg border-2 p-4 transition-all',
accountCategory === 'oauth-based'
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
: 'border-gray-200 dark:border-dark-600 hover:border-primary-300'
]"
>
<input
v-model="accountCategory"
type="radio"
value="oauth-based"
class="sr-only"
/>
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-orange-500 to-orange-600">
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z" />
</svg>
</div>
<div>
<span class="block text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.accounts.claudeCode') }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.oauthSetupToken') }}</span>
</div>
</div>
<div
v-if="accountCategory === 'oauth-based'"
class="absolute right-2 top-2 flex h-5 w-5 items-center justify-center rounded-full bg-primary-500"
>
<svg class="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</div>
</label>
<label
:class="[
'relative flex cursor-pointer rounded-lg border-2 p-4 transition-all',
accountCategory === 'apikey'
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
: 'border-gray-200 dark:border-dark-600 hover:border-primary-300'
]"
>
<input
v-model="accountCategory"
type="radio"
value="apikey"
class="sr-only"
/>
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-purple-500 to-purple-600">
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="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-semibold text-gray-900 dark:text-white">{{ t('admin.accounts.claudeConsole') }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.apiKey') }}</span>
</div>
</div>
<div
v-if="accountCategory === 'apikey'"
class="absolute right-2 top-2 flex h-5 w-5 items-center justify-center rounded-full bg-primary-500"
>
<svg class="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</div>
</label>
</div>
</div>
<!-- Add Method (only for OAuth-based type) -->
<div v-if="isOAuthFlow">
<label class="input-label">{{ t('admin.accounts.addMethod') }}</label>
<div class="flex gap-4 mt-2">
<label class="flex cursor-pointer items-center">
<input
v-model="addMethod"
type="radio"
value="oauth"
class="mr-2 text-primary-600 focus:ring-primary-500"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Oauth</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="addMethod"
type="radio"
value="setup-token"
class="mr-2 text-primary-600 focus:ring-primary-500"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.accounts.setupTokenLongLived') }}</span>
</label>
</div>
</div>
<!-- API Key input (only for apikey type) -->
<div v-if="form.type === 'apikey'" class="space-y-4">
<div>
<label class="input-label">{{ t('admin.accounts.baseUrl') }}</label>
<input
v-model="apiKeyBaseUrl"
type="text"
class="input"
placeholder="https://api.anthropic.com"
/>
<p class="input-hint">{{ t('admin.accounts.baseUrlHint') }}</p>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.apiKeyRequired') }}</label>
<input
v-model="apiKeyValue"
type="password"
required
class="input font-mono"
:placeholder="t('admin.accounts.apiKeyPlaceholder')"
/>
<p class="input-hint">{{ t('admin.accounts.apiKeyHint') }}</p>
</div>
<!-- Model Restriction Section -->
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
<!-- Mode Toggle -->
<div class="flex gap-2 mb-4">
<button
type="button"
@click="modelRestrictionMode = 'whitelist'"
:class="[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
modelRestrictionMode === 'whitelist'
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]"
>
<svg class="w-4 h-4 inline mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ t('admin.accounts.modelWhitelist') }}
</button>
<button
type="button"
@click="modelRestrictionMode = 'mapping'"
:class="[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
modelRestrictionMode === 'mapping'
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]"
>
<svg class="w-4 h-4 inline mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
{{ t('admin.accounts.modelMapping') }}
</button>
</div>
<!-- Whitelist Mode -->
<div v-if="modelRestrictionMode === 'whitelist'">
<div class="mb-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-3">
<p class="text-xs text-blue-700 dark:text-blue-400">
<svg class="w-4 h-4 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ t('admin.accounts.selectAllowedModels') }}
</p>
</div>
<!-- Model Checkbox List -->
<div class="grid grid-cols-2 gap-2 mb-3">
<label
v-for="model in commonModels"
:key="model.value"
class="flex cursor-pointer items-center rounded-lg border p-3 transition-all hover:bg-gray-50 dark:border-dark-600 dark:hover:bg-dark-700"
:class="allowedModels.includes(model.value) ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20' : 'border-gray-200'"
>
<input
type="checkbox"
:value="model.value"
v-model="allowedModels"
class="mr-2 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ model.label }}</span>
</label>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
<span v-if="allowedModels.length === 0">{{ t('admin.accounts.supportsAllModels') }}</span>
</p>
</div>
<!-- Mapping Mode -->
<div v-else>
<div class="mb-3 rounded-lg bg-purple-50 dark:bg-purple-900/20 p-3">
<p class="text-xs text-purple-700 dark:text-purple-400">
<svg class="w-4 h-4 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ t('admin.accounts.mapRequestModels') }}
</p>
</div>
<!-- Model Mapping List -->
<div v-if="modelMappings.length > 0" class="space-y-2 mb-3">
<div
v-for="(mapping, index) in modelMappings"
:key="index"
class="flex items-center gap-2"
>
<input
v-model="mapping.from"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.requestModel')"
/>
<svg class="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
<input
v-model="mapping.to"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.actualModel')"
/>
<button
type="button"
@click="removeModelMapping(index)"
class="p-2 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
<button
type="button"
@click="addModelMapping"
class="w-full rounded-lg border-2 border-dashed border-gray-300 dark:border-dark-500 px-4 py-2 text-gray-600 dark:text-gray-400 transition-colors hover:border-gray-400 hover:text-gray-700 dark:hover:border-dark-400 dark:hover:text-gray-300 mb-3"
>
<svg class="w-4 h-4 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{{ t('admin.accounts.addMapping') }}
</button>
<!-- Quick Add Buttons -->
<div class="flex flex-wrap gap-2">
<button
v-for="preset in presetMappings"
:key="preset.label"
type="button"
@click="addPresetMapping(preset.from, preset.to)"
:class="[
'rounded-lg px-3 py-1 text-xs transition-colors',
preset.color
]"
>
+ {{ preset.label }}
</button>
</div>
</div>
</div>
<!-- Custom Error Codes Section -->
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
<div class="flex items-center justify-between mb-3">
<div>
<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>
</div>
<button
type="button"
@click="customErrorCodesEnabled = !customErrorCodesEnabled"
: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',
customErrorCodesEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
customErrorCodesEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
<div v-if="customErrorCodesEnabled" class="space-y-3">
<div class="rounded-lg bg-amber-50 dark:bg-amber-900/20 p-3">
<p class="text-xs text-amber-700 dark:text-amber-400">
<svg class="w-4 h-4 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{{ t('admin.accounts.customErrorCodesWarning') }}
</p>
</div>
<!-- Error Code Buttons -->
<div class="flex flex-wrap gap-2">
<button
v-for="code in commonErrorCodes"
:key="code.value"
type="button"
@click="toggleErrorCode(code.value)"
:class="[
'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
selectedErrorCodes.includes(code.value)
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 ring-1 ring-red-500'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]"
>
{{ code.value }} {{ code.label }}
</button>
</div>
<!-- Manual input -->
<div class="flex items-center gap-2">
<input
v-model="customErrorCodeInput"
type="number"
min="100"
max="599"
class="input flex-1"
:placeholder="t('admin.accounts.enterErrorCode')"
@keyup.enter="addCustomErrorCode"
/>
<button
type="button"
@click="addCustomErrorCode"
class="btn btn-secondary px-3"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
<!-- Selected codes summary -->
<div class="flex flex-wrap gap-1.5">
<span
v-for="code in selectedErrorCodes.sort((a, b) => a - b)"
:key="code"
class="inline-flex items-center gap-1 rounded-full bg-red-100 dark:bg-red-900/30 px-2.5 py-0.5 text-sm font-medium text-red-700 dark:text-red-400"
>
{{ code }}
<button
type="button"
@click="removeErrorCode(code)"
class="hover:text-red-900 dark:hover:text-red-300"
>
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
<span v-if="selectedErrorCodes.length === 0" class="text-xs text-gray-400">
{{ t('admin.accounts.noneSelectedUsesDefault') }}
</span>
</div>
</div>
</div>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.proxy') }}</label>
<ProxySelector
v-model="form.proxy_id"
:proxies="proxies"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="input-label">{{ t('admin.accounts.concurrency') }}</label>
<input
v-model.number="form.concurrency"
type="number"
min="1"
class="input"
/>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.priority') }}</label>
<input
v-model.number="form.priority"
type="number"
min="1"
class="input"
/>
<p class="input-hint">{{ t('admin.accounts.priorityHint') }}</p>
</div>
</div>
<!-- Group Selection -->
<GroupSelector
v-model="form.group_ids"
:groups="groups"
/>
<div class="flex justify-end gap-3 pt-4">
<button
@click="handleClose"
type="button"
class="btn btn-secondary"
>
{{ t('common.cancel') }}
</button>
<button
type="submit"
:disabled="submitting"
class="btn btn-primary"
>
<svg
v-if="submitting"
class="animate-spin -ml-1 mr-2 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ isOAuthFlow ? t('common.next') : (submitting ? t('admin.accounts.creating') : t('common.create')) }}
</button>
</div>
</form>
<!-- Step 2: OAuth Authorization -->
<div v-else class="space-y-5">
<OAuthAuthorizationFlow
ref="oauthFlowRef"
:add-method="addMethod"
:auth-url="oauth.authUrl.value"
:session-id="oauth.sessionId.value"
:loading="oauth.loading.value"
:error="oauth.error.value"
:show-help="true"
:show-proxy-warning="!!form.proxy_id"
:allow-multiple="true"
@generate-url="handleGenerateUrl"
@cookie-auth="handleCookieAuth"
/>
<div class="flex justify-between gap-3 pt-4">
<button
type="button"
class="btn btn-secondary"
@click="goBackToBasicInfo"
>
{{ t('common.back') }}
</button>
<button
v-if="oauthFlowRef?.inputMethod?.value === 'manual'"
type="button"
:disabled="!canExchangeCode"
class="btn btn-primary"
@click="handleExchangeCode"
>
<svg
v-if="oauth.loading.value"
class="animate-spin -ml-1 mr-2 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ oauth.loading.value ? t('admin.accounts.oauth.verifying') : t('admin.accounts.oauth.completeAuth') }}
</button>
</div>
</div>
</Modal>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import { useAccountOAuth, type AddMethod } from '@/composables/useAccountOAuth'
import type { Proxy, Group, AccountPlatform, AccountType } from '@/types'
import Modal from '@/components/common/Modal.vue'
import Select from '@/components/common/Select.vue'
import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue'
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
const { t } = useI18n()
interface Props {
show: boolean
proxies: Proxy[]
groups: Group[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
close: []
created: []
}>()
const appStore = useAppStore()
// OAuth composable
const oauth = useAccountOAuth()
// Refs
const oauthFlowRef = ref<InstanceType<typeof OAuthAuthorizationFlow> | null>(null)
// Model mapping type
interface ModelMapping {
from: string
to: string
}
// State
const step = ref(1)
const submitting = ref(false)
const accountCategory = ref<'oauth-based' | 'apikey'>('oauth-based') // UI selection for account category
const addMethod = ref<AddMethod>('oauth') // For oauth-based: 'oauth' or 'setup-token'
const apiKeyBaseUrl = ref('https://api.anthropic.com')
const apiKeyValue = ref('')
const modelMappings = ref<ModelMapping[]>([])
const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
const allowedModels = ref<string[]>([])
const customErrorCodesEnabled = ref(false)
const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null)
// Common models for whitelist
const commonModels = [
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5' },
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
{ value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5' },
{ value: 'claude-3-5-haiku-20241022', label: 'Claude 3.5 Haiku' },
{ value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5' },
{ value: 'claude-3-opus-20240229', label: 'Claude 3 Opus' },
{ value: 'claude-3-5-sonnet-20241022', label: 'Claude 3.5 Sonnet' },
{ value: 'claude-3-haiku-20240307', label: 'Claude 3 Haiku' }
]
// Preset mappings for quick add
const presetMappings = [
{ label: 'Sonnet 4', from: 'claude-sonnet-4-20250514', to: 'claude-sonnet-4-20250514', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
{ 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' }
]
// Common HTTP error codes for quick selection
const commonErrorCodes = [
{ value: 401, label: 'Unauthorized' },
{ value: 403, label: 'Forbidden' },
{ value: 429, label: 'Rate Limit' },
{ value: 500, label: 'Server Error' },
{ value: 502, label: 'Bad Gateway' },
{ value: 503, label: 'Unavailable' },
{ value: 529, label: 'Overloaded' }
]
const form = reactive({
name: '',
platform: 'anthropic' as AccountPlatform,
type: 'oauth' as AccountType, // Will be 'oauth', 'setup-token', or 'apikey'
credentials: {} as Record<string, unknown>,
proxy_id: null as number | null,
concurrency: 10,
priority: 1,
group_ids: [] as number[]
})
// Helper to check if current type needs OAuth flow
const isOAuthFlow = computed(() => accountCategory.value === 'oauth-based')
const canExchangeCode = computed(() => {
const authCode = oauthFlowRef.value?.authCode?.value || ''
return authCode.trim() && oauth.sessionId.value && !oauth.loading.value
})
// Watchers
watch(() => props.show, (newVal) => {
if (!newVal) {
resetForm()
}
})
// Sync form.type based on accountCategory and addMethod
watch([accountCategory, addMethod], ([category, method]) => {
if (category === 'oauth-based') {
form.type = method as AccountType // 'oauth' or 'setup-token'
} else {
form.type = 'apikey'
}
}, { immediate: true })
// Model mapping helpers
const addModelMapping = () => {
modelMappings.value.push({ from: '', to: '' })
}
const removeModelMapping = (index: number) => {
modelMappings.value.splice(index, 1)
}
const addPresetMapping = (from: string, to: string) => {
// Check if mapping already exists
const exists = modelMappings.value.some(m => m.from === from)
if (exists) {
appStore.showInfo(t('admin.accounts.mappingExists', { model: from }))
return
}
modelMappings.value.push({ from, to })
}
// Error code toggle helper
const toggleErrorCode = (code: number) => {
const index = selectedErrorCodes.value.indexOf(code)
if (index === -1) {
selectedErrorCodes.value.push(code)
} else {
selectedErrorCodes.value.splice(index, 1)
}
}
// Add custom error code from input
const addCustomErrorCode = () => {
const code = customErrorCodeInput.value
if (code === null || code < 100 || code > 599) {
appStore.showError(t('admin.accounts.invalidErrorCode'))
return
}
if (selectedErrorCodes.value.includes(code)) {
appStore.showInfo(t('admin.accounts.errorCodeExists'))
return
}
selectedErrorCodes.value.push(code)
customErrorCodeInput.value = null
}
// Remove error code
const removeErrorCode = (code: number) => {
const index = selectedErrorCodes.value.indexOf(code)
if (index !== -1) {
selectedErrorCodes.value.splice(index, 1)
}
}
const buildModelMappingObject = (): Record<string, string> | null => {
const mapping: Record<string, string> = {}
if (modelRestrictionMode.value === 'whitelist') {
// Whitelist mode: map model to itself
for (const model of allowedModels.value) {
mapping[model] = model
}
} else {
// Mapping mode: use custom mappings
for (const m of modelMappings.value) {
const from = m.from.trim()
const to = m.to.trim()
if (from && to) {
mapping[from] = to
}
}
}
return Object.keys(mapping).length > 0 ? mapping : null
}
// Methods
const resetForm = () => {
step.value = 1
form.name = ''
form.platform = 'anthropic'
form.type = 'oauth'
form.credentials = {}
form.proxy_id = null
form.concurrency = 10
form.priority = 1
form.group_ids = []
accountCategory.value = 'oauth-based'
addMethod.value = 'oauth'
apiKeyBaseUrl.value = 'https://api.anthropic.com'
apiKeyValue.value = ''
modelMappings.value = []
modelRestrictionMode.value = 'whitelist'
allowedModels.value = []
customErrorCodesEnabled.value = false
selectedErrorCodes.value = []
customErrorCodeInput.value = null
oauth.resetState()
oauthFlowRef.value?.reset()
}
const handleClose = () => {
emit('close')
}
const handleSubmit = async () => {
// For OAuth-based type, handle OAuth flow (goes to step 2)
if (isOAuthFlow.value) {
if (!form.name.trim()) {
appStore.showError(t('admin.accounts.pleaseEnterAccountName'))
return
}
step.value = 2
return
}
// For apikey type, create directly
if (!apiKeyValue.value.trim()) {
appStore.showError(t('admin.accounts.pleaseEnterApiKey'))
return
}
// Build credentials with optional model mapping
const credentials: Record<string, unknown> = {
base_url: apiKeyBaseUrl.value.trim() || 'https://api.anthropic.com',
api_key: apiKeyValue.value.trim()
}
// Add model mapping if configured
const modelMapping = buildModelMappingObject()
if (modelMapping) {
credentials.model_mapping = modelMapping
}
// Add custom error codes if enabled
if (customErrorCodesEnabled.value) {
credentials.custom_error_codes_enabled = true
credentials.custom_error_codes = [...selectedErrorCodes.value]
}
form.credentials = credentials
submitting.value = true
try {
await adminAPI.accounts.create(form)
appStore.showSuccess(t('admin.accounts.accountCreated'))
emit('created')
handleClose()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToCreate'))
} finally {
submitting.value = false
}
}
const goBackToBasicInfo = () => {
step.value = 1
oauth.resetState()
oauthFlowRef.value?.reset()
}
const handleGenerateUrl = async () => {
await oauth.generateAuthUrl(addMethod.value, form.proxy_id)
}
const handleExchangeCode = async () => {
const authCode = oauthFlowRef.value?.authCode?.value || ''
if (!authCode.trim() || !oauth.sessionId.value) return
oauth.loading.value = true
oauth.error.value = ''
try {
const proxyConfig = form.proxy_id ? { proxy_id: form.proxy_id } : {}
const endpoint = addMethod.value === 'oauth'
? '/admin/accounts/exchange-code'
: '/admin/accounts/exchange-setup-token-code'
const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, {
session_id: oauth.sessionId.value,
code: authCode.trim(),
...proxyConfig
})
const extra = oauth.buildExtraInfo(tokenInfo)
await adminAPI.accounts.create({
name: form.name,
platform: form.platform,
type: addMethod.value, // Use addMethod as type: 'oauth' or 'setup-token'
credentials: tokenInfo,
extra,
proxy_id: form.proxy_id,
concurrency: form.concurrency,
priority: form.priority
})
appStore.showSuccess(t('admin.accounts.accountCreated'))
emit('created')
handleClose()
} catch (error: any) {
oauth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(oauth.error.value)
} finally {
oauth.loading.value = false
}
}
const handleCookieAuth = async (sessionKey: string) => {
oauth.loading.value = true
oauth.error.value = ''
try {
const proxyConfig = form.proxy_id ? { proxy_id: form.proxy_id } : {}
const keys = oauth.parseSessionKeys(sessionKey)
if (keys.length === 0) {
oauth.error.value = t('admin.accounts.oauth.pleaseEnterSessionKey')
return
}
const endpoint = addMethod.value === 'oauth'
? '/admin/accounts/cookie-auth'
: '/admin/accounts/setup-token-cookie-auth'
let successCount = 0
let failedCount = 0
const errors: string[] = []
for (let i = 0; i < keys.length; i++) {
try {
const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, {
session_id: '',
code: keys[i],
...proxyConfig
})
const extra = oauth.buildExtraInfo(tokenInfo)
const accountName = keys.length > 1 ? `${form.name} #${i + 1}` : form.name
await adminAPI.accounts.create({
name: accountName,
platform: form.platform,
type: addMethod.value, // Use addMethod as type: 'oauth' or 'setup-token'
credentials: tokenInfo,
extra,
proxy_id: form.proxy_id,
concurrency: form.concurrency,
priority: form.priority
})
successCount++
} catch (error: any) {
failedCount++
errors.push(t('admin.accounts.oauth.keyAuthFailed', { index: i + 1, error: error.response?.data?.detail || t('admin.accounts.oauth.authFailed') }))
}
}
if (successCount > 0) {
appStore.showSuccess(t('admin.accounts.oauth.successCreated', { count: successCount }))
if (failedCount === 0) {
emit('created')
handleClose()
} else {
emit('created')
}
}
if (failedCount > 0) {
oauth.error.value = errors.join('\n')
}
} catch (error: any) {
oauth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.cookieAuthFailed')
} finally {
oauth.loading.value = false
}
}
</script>

View File

@@ -0,0 +1,646 @@
<template>
<Modal
:show="show"
:title="t('admin.accounts.editAccount')"
size="lg"
@close="handleClose"
>
<form v-if="account" @submit.prevent="handleSubmit" class="space-y-5">
<div>
<label class="input-label">{{ t('common.name') }}</label>
<input
v-model="form.name"
type="text"
required
class="input"
/>
</div>
<!-- API Key fields (only for apikey type) -->
<div v-if="account.type === 'apikey'" class="space-y-4">
<div>
<label class="input-label">{{ t('admin.accounts.baseUrl') }}</label>
<input
v-model="editBaseUrl"
type="text"
class="input"
placeholder="https://api.anthropic.com"
/>
<p class="input-hint">{{ t('admin.accounts.baseUrlHint') }}</p>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.apiKey') }}</label>
<input
v-model="editApiKey"
type="password"
class="input font-mono"
:placeholder="t('admin.accounts.leaveEmptyToKeep')"
/>
<p class="input-hint">{{ t('admin.accounts.leaveEmptyToKeep') }}</p>
</div>
<!-- Model Restriction Section -->
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
<!-- Mode Toggle -->
<div class="flex gap-2 mb-4">
<button
type="button"
@click="modelRestrictionMode = 'whitelist'"
:class="[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
modelRestrictionMode === 'whitelist'
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]"
>
<svg class="w-4 h-4 inline mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ t('admin.accounts.modelWhitelist') }}
</button>
<button
type="button"
@click="modelRestrictionMode = 'mapping'"
:class="[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
modelRestrictionMode === 'mapping'
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]"
>
<svg class="w-4 h-4 inline mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
{{ t('admin.accounts.modelMapping') }}
</button>
</div>
<!-- Whitelist Mode -->
<div v-if="modelRestrictionMode === 'whitelist'">
<div class="mb-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-3">
<p class="text-xs text-blue-700 dark:text-blue-400">
<svg class="w-4 h-4 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ t('admin.accounts.selectAllowedModels') }}
</p>
</div>
<!-- Model Checkbox List -->
<div class="grid grid-cols-2 gap-2 mb-3">
<label
v-for="model in commonModels"
:key="model.value"
class="flex cursor-pointer items-center rounded-lg border p-3 transition-all hover:bg-gray-50 dark:border-dark-600 dark:hover:bg-dark-700"
:class="allowedModels.includes(model.value) ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20' : 'border-gray-200'"
>
<input
type="checkbox"
:value="model.value"
v-model="allowedModels"
class="mr-2 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ model.label }}</span>
</label>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
<span v-if="allowedModels.length === 0">{{ t('admin.accounts.supportsAllModels') }}</span>
</p>
</div>
<!-- Mapping Mode -->
<div v-else>
<div class="mb-3 rounded-lg bg-purple-50 dark:bg-purple-900/20 p-3">
<p class="text-xs text-purple-700 dark:text-purple-400">
<svg class="w-4 h-4 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ t('admin.accounts.mapRequestModels') }}
</p>
</div>
<!-- Model Mapping List -->
<div v-if="modelMappings.length > 0" class="space-y-2 mb-3">
<div
v-for="(mapping, index) in modelMappings"
:key="index"
class="flex items-center gap-2"
>
<input
v-model="mapping.from"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.requestModel')"
/>
<svg class="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
<input
v-model="mapping.to"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.actualModel')"
/>
<button
type="button"
@click="removeModelMapping(index)"
class="p-2 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
<button
type="button"
@click="addModelMapping"
class="w-full rounded-lg border-2 border-dashed border-gray-300 dark:border-dark-500 px-4 py-2 text-gray-600 dark:text-gray-400 transition-colors hover:border-gray-400 hover:text-gray-700 dark:hover:border-dark-400 dark:hover:text-gray-300 mb-3"
>
<svg class="w-4 h-4 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{{ t('admin.accounts.addMapping') }}
</button>
<!-- Quick Add Buttons -->
<div class="flex flex-wrap gap-2">
<button
v-for="preset in presetMappings"
:key="preset.label"
type="button"
@click="addPresetMapping(preset.from, preset.to)"
:class="[
'rounded-lg px-3 py-1 text-xs transition-colors',
preset.color
]"
>
+ {{ preset.label }}
</button>
</div>
</div>
</div>
<!-- Custom Error Codes Section -->
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
<div class="flex items-center justify-between mb-3">
<div>
<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>
</div>
<button
type="button"
@click="customErrorCodesEnabled = !customErrorCodesEnabled"
: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',
customErrorCodesEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
customErrorCodesEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
<div v-if="customErrorCodesEnabled" class="space-y-3">
<div class="rounded-lg bg-amber-50 dark:bg-amber-900/20 p-3">
<p class="text-xs text-amber-700 dark:text-amber-400">
<svg class="w-4 h-4 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{{ t('admin.accounts.customErrorCodesWarning') }}
</p>
</div>
<!-- Error Code Buttons -->
<div class="flex flex-wrap gap-2">
<button
v-for="code in commonErrorCodes"
:key="code.value"
type="button"
@click="toggleErrorCode(code.value)"
:class="[
'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
selectedErrorCodes.includes(code.value)
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 ring-1 ring-red-500'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]"
>
{{ code.value }} {{ code.label }}
</button>
</div>
<!-- Manual input -->
<div class="flex items-center gap-2">
<input
v-model="customErrorCodeInput"
type="number"
min="100"
max="599"
class="input flex-1"
:placeholder="t('admin.accounts.enterErrorCode')"
@keyup.enter="addCustomErrorCode"
/>
<button
type="button"
@click="addCustomErrorCode"
class="btn btn-secondary px-3"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
<!-- Selected codes summary -->
<div class="flex flex-wrap gap-1.5">
<span
v-for="code in selectedErrorCodes.sort((a, b) => a - b)"
:key="code"
class="inline-flex items-center gap-1 rounded-full bg-red-100 dark:bg-red-900/30 px-2.5 py-0.5 text-sm font-medium text-red-700 dark:text-red-400"
>
{{ code }}
<button
type="button"
@click="removeErrorCode(code)"
class="hover:text-red-900 dark:hover:text-red-300"
>
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
<span v-if="selectedErrorCodes.length === 0" class="text-xs text-gray-400">
{{ t('admin.accounts.noneSelectedUsesDefault') }}
</span>
</div>
</div>
</div>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.proxy') }}</label>
<ProxySelector
v-model="form.proxy_id"
:proxies="proxies"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="input-label">{{ t('admin.accounts.concurrency') }}</label>
<input
v-model.number="form.concurrency"
type="number"
min="1"
class="input"
/>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.priority') }}</label>
<input
v-model.number="form.priority"
type="number"
min="1"
class="input"
/>
</div>
</div>
<div>
<label class="input-label">{{ t('common.status') }}</label>
<Select
v-model="form.status"
:options="statusOptions"
/>
</div>
<!-- Group Selection -->
<GroupSelector
v-model="form.group_ids"
:groups="groups"
/>
<div class="flex justify-end gap-3 pt-4">
<button
@click="handleClose"
type="button"
class="btn btn-secondary"
>
{{ t('common.cancel') }}
</button>
<button
type="submit"
:disabled="submitting"
class="btn btn-primary"
>
<svg
v-if="submitting"
class="animate-spin -ml-1 mr-2 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ submitting ? t('admin.accounts.updating') : t('common.update') }}
</button>
</div>
</form>
</Modal>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import type { Account, Proxy, Group } from '@/types'
import Modal from '@/components/common/Modal.vue'
import Select from '@/components/common/Select.vue'
import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue'
interface Props {
show: boolean
account: Account | null
proxies: Proxy[]
groups: Group[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
close: []
updated: []
}>()
const { t } = useI18n()
const appStore = useAppStore()
// Model mapping type
interface ModelMapping {
from: string
to: string
}
// State
const submitting = ref(false)
const editBaseUrl = ref('https://api.anthropic.com')
const editApiKey = ref('')
const modelMappings = ref<ModelMapping[]>([])
const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
const allowedModels = ref<string[]>([])
const customErrorCodesEnabled = ref(false)
const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null)
// Common models for whitelist
const commonModels = [
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5' },
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
{ value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5' },
{ value: 'claude-3-5-haiku-20241022', label: 'Claude 3.5 Haiku' },
{ value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5' },
{ value: 'claude-3-opus-20240229', label: 'Claude 3 Opus' },
{ value: 'claude-3-5-sonnet-20241022', label: 'Claude 3.5 Sonnet' },
{ value: 'claude-3-haiku-20240307', label: 'Claude 3 Haiku' }
]
// Preset mappings for quick add
const presetMappings = [
{ label: 'Sonnet 4', from: 'claude-sonnet-4-20250514', to: 'claude-sonnet-4-20250514', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
{ 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' }
]
// Common HTTP error codes for quick selection
const commonErrorCodes = [
{ value: 401, label: 'Unauthorized' },
{ value: 403, label: 'Forbidden' },
{ value: 429, label: 'Rate Limit' },
{ value: 500, label: 'Server Error' },
{ value: 502, label: 'Bad Gateway' },
{ value: 503, label: 'Unavailable' },
{ value: 529, label: 'Overloaded' }
]
const form = reactive({
name: '',
proxy_id: null as number | null,
concurrency: 1,
priority: 1,
status: 'active' as 'active' | 'inactive',
group_ids: [] as number[]
})
const statusOptions = computed(() => [
{ value: 'active', label: t('common.active') },
{ value: 'inactive', label: t('common.inactive') }
])
// Watchers
watch(() => props.account, (newAccount) => {
if (newAccount) {
form.name = newAccount.name
form.proxy_id = newAccount.proxy_id
form.concurrency = newAccount.concurrency
form.priority = newAccount.priority
form.status = newAccount.status as 'active' | 'inactive'
form.group_ids = newAccount.group_ids || []
// Initialize API Key fields for apikey type
if (newAccount.type === 'apikey' && newAccount.credentials) {
const credentials = newAccount.credentials as Record<string, unknown>
editBaseUrl.value = credentials.base_url as string || 'https://api.anthropic.com'
// Load model mappings and detect mode
const existingMappings = credentials.model_mapping as Record<string, string> | undefined
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'
modelMappings.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 {
editBaseUrl.value = 'https://api.anthropic.com'
modelRestrictionMode.value = 'whitelist'
modelMappings.value = []
allowedModels.value = []
customErrorCodesEnabled.value = false
selectedErrorCodes.value = []
}
editApiKey.value = ''
}
}, { immediate: true })
// Model mapping helpers
const addModelMapping = () => {
modelMappings.value.push({ from: '', to: '' })
}
const removeModelMapping = (index: number) => {
modelMappings.value.splice(index, 1)
}
const addPresetMapping = (from: string, to: string) => {
const exists = modelMappings.value.some(m => m.from === from)
if (exists) {
appStore.showInfo(t('admin.accounts.mappingExists', { model: from }))
return
}
modelMappings.value.push({ from, to })
}
// Error code toggle helper
const toggleErrorCode = (code: number) => {
const index = selectedErrorCodes.value.indexOf(code)
if (index === -1) {
selectedErrorCodes.value.push(code)
} else {
selectedErrorCodes.value.splice(index, 1)
}
}
// Add custom error code from input
const addCustomErrorCode = () => {
const code = customErrorCodeInput.value
if (code === null || code < 100 || code > 599) {
appStore.showError(t('admin.accounts.invalidErrorCode'))
return
}
if (selectedErrorCodes.value.includes(code)) {
appStore.showInfo(t('admin.accounts.errorCodeExists'))
return
}
selectedErrorCodes.value.push(code)
customErrorCodeInput.value = null
}
// Remove error code
const removeErrorCode = (code: number) => {
const index = selectedErrorCodes.value.indexOf(code)
if (index !== -1) {
selectedErrorCodes.value.splice(index, 1)
}
}
const buildModelMappingObject = (): Record<string, string> | null => {
const mapping: Record<string, string> = {}
if (modelRestrictionMode.value === 'whitelist') {
// Whitelist mode: model maps to itself
for (const model of allowedModels.value) {
mapping[model] = model
}
} else {
// Mapping mode: use the mapping entries
for (const m of modelMappings.value) {
const from = m.from.trim()
const to = m.to.trim()
if (from && to) {
mapping[from] = to
}
}
}
return Object.keys(mapping).length > 0 ? mapping : null
}
// Methods
const handleClose = () => {
emit('close')
}
const handleSubmit = async () => {
if (!props.account) return
submitting.value = true
try {
const updatePayload: Record<string, unknown> = { ...form }
// For apikey type, handle credentials update
if (props.account.type === 'apikey') {
const currentCredentials = props.account.credentials as Record<string, unknown> || {}
const newBaseUrl = editBaseUrl.value.trim() || 'https://api.anthropic.com'
const modelMapping = buildModelMappingObject()
// Always update credentials for apikey type to handle model mapping changes
const newCredentials: Record<string, unknown> = {
base_url: newBaseUrl
}
// Handle API key
if (editApiKey.value.trim()) {
// User provided a new API key
newCredentials.api_key = editApiKey.value.trim()
} else if (currentCredentials.api_key) {
// Preserve existing api_key
newCredentials.api_key = currentCredentials.api_key
} else {
appStore.showError(t('admin.accounts.apiKeyIsRequired'))
submitting.value = false
return
}
// Add model mapping if configured
if (modelMapping) {
newCredentials.model_mapping = modelMapping
}
// Add custom error codes if enabled
if (customErrorCodesEnabled.value) {
newCredentials.custom_error_codes_enabled = true
newCredentials.custom_error_codes = [...selectedErrorCodes.value]
}
updatePayload.credentials = newCredentials
}
await adminAPI.accounts.update(props.account.id, updatePayload)
appStore.showSuccess(t('admin.accounts.accountUpdated'))
emit('updated')
handleClose()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToUpdate'))
} finally {
submitting.value = false
}
}
</script>

View File

@@ -0,0 +1,364 @@
<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="flex items-start gap-4">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-blue-500">
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
</svg>
</div>
<div class="flex-1">
<h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">{{ t('admin.accounts.oauth.title') }}</h4>
<!-- Auth Method Selection -->
<div class="mb-4">
<label class="mb-2 block text-sm font-medium text-blue-800 dark:text-blue-300">
{{ methodLabel }}
</label>
<div class="flex gap-4">
<label class="flex cursor-pointer items-center gap-2">
<input
v-model="inputMethod"
type="radio"
value="manual"
class="text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-blue-900 dark:text-blue-200">{{ t('admin.accounts.oauth.manualAuth') }}</span>
</label>
<label class="flex cursor-pointer items-center gap-2">
<input
v-model="inputMethod"
type="radio"
value="cookie"
class="text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-blue-900 dark:text-blue-200">{{ t('admin.accounts.oauth.cookieAutoAuth') }}</span>
</label>
</div>
</div>
<!-- Cookie Auto-Auth Form -->
<div v-if="inputMethod === 'cookie'" class="space-y-4">
<div class="rounded-lg border border-blue-300 dark:border-blue-600 bg-white/80 dark:bg-gray-800/80 p-4">
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
{{ t('admin.accounts.oauth.cookieAutoAuthDesc') }}
</p>
<!-- sessionKey Input -->
<div class="mb-4">
<label class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300">
<svg class="w-4 h-4 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
</svg>
{{ t('admin.accounts.oauth.sessionKey') }}
<span
v-if="parsedKeyCount > 1 && allowMultiple"
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
>
{{ t('admin.accounts.oauth.keysCount', { count: parsedKeyCount }) }}
</span>
<button
v-if="showHelp"
type="button"
class="text-blue-500 hover:text-blue-600"
@click="showHelpDialog = !showHelpDialog"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
</svg>
</button>
</label>
<textarea
v-model="sessionKeyInput"
rows="3"
class="input w-full font-mono text-sm resize-y"
:placeholder="allowMultiple ? t('admin.accounts.oauth.sessionKeyPlaceholder') : t('admin.accounts.oauth.sessionKeyPlaceholderSingle')"
></textarea>
<p
v-if="parsedKeyCount > 1 && allowMultiple"
class="mt-1 text-xs text-blue-600 dark:text-blue-400"
>
{{ t('admin.accounts.oauth.batchCreateAccounts', { count: parsedKeyCount }) }}
</p>
</div>
<!-- Help Section -->
<div
v-if="showHelpDialog && showHelp"
class="mb-4 rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/30 p-3"
>
<h5 class="mb-2 font-semibold text-amber-800 dark:text-amber-200">
{{ t('admin.accounts.oauth.howToGetSessionKey') }}
</h5>
<ol class="list-inside list-decimal space-y-1 text-xs text-amber-700 dark:text-amber-300">
<li v-html="t('admin.accounts.oauth.step1')"></li>
<li v-html="t('admin.accounts.oauth.step2')"></li>
<li v-html="t('admin.accounts.oauth.step3')"></li>
<li v-html="t('admin.accounts.oauth.step4')"></li>
<li v-html="t('admin.accounts.oauth.step5')"></li>
<li v-html="t('admin.accounts.oauth.step6')"></li>
</ol>
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400" v-html="t('admin.accounts.oauth.sessionKeyFormat')"></p>
</div>
<!-- Error Message -->
<div
v-if="error"
class="mb-4 rounded-lg border border-red-200 bg-red-50 dark:border-red-700 dark:bg-red-900/30 p-3"
>
<p class="text-sm text-red-600 dark:text-red-400 whitespace-pre-line">
{{ error }}
</p>
</div>
<!-- Auth Button -->
<button
type="button"
class="btn btn-primary w-full"
:disabled="loading || !sessionKeyInput.trim()"
@click="handleCookieAuth"
>
<svg v-if="loading" class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<svg v-else class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
</svg>
{{ loading ? t('admin.accounts.oauth.authorizing') : t('admin.accounts.oauth.startAutoAuth') }}
</button>
</div>
</div>
<!-- Manual Authorization Flow -->
<div v-else class="space-y-4">
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
{{ t('admin.accounts.oauth.followSteps') }}
</p>
<!-- Step 1: Generate Auth URL -->
<div class="rounded-lg border border-blue-300 dark:border-blue-600 bg-white/80 dark:bg-gray-800/80 p-4">
<div class="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">
1
</div>
<div class="flex-1">
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
{{ t('admin.accounts.oauth.step1GenerateUrl') }}
</p>
<button
v-if="!authUrl"
type="button"
:disabled="loading"
class="btn btn-primary text-sm"
@click="handleGenerateUrl"
>
<svg v-if="loading" class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<svg v-else class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
</svg>
{{ loading ? t('admin.accounts.oauth.generating') : t('admin.accounts.oauth.generateAuthUrl') }}
</button>
<div v-else class="space-y-3">
<div class="flex items-center gap-2">
<input
:value="authUrl"
readonly
type="text"
class="input flex-1 bg-gray-50 dark:bg-gray-700 font-mono text-xs"
/>
<button
type="button"
class="btn btn-secondary p-2"
title="Copy URL"
@click="handleCopyUrl"
>
<svg v-if="!copied" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
</svg>
<svg v-else class="w-4 h-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</button>
</div>
<button
type="button"
class="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400"
@click="handleRegenerate"
>
<svg class="w-3 h-3 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
{{ t('admin.accounts.oauth.regenerate') }}
</button>
</div>
</div>
</div>
</div>
<!-- Step 2: Open URL and authorize -->
<div class="rounded-lg border border-blue-300 dark:border-blue-600 bg-white/80 dark:bg-gray-800/80 p-4">
<div class="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">
2
</div>
<div class="flex-1">
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
{{ t('admin.accounts.oauth.step2OpenUrl') }}
</p>
<p class="text-sm text-blue-700 dark:text-blue-300">
{{ t('admin.accounts.oauth.openUrlDesc') }}
</p>
<div v-if="showProxyWarning" class="mt-2 rounded border border-yellow-300 dark:border-yellow-700 bg-yellow-50 dark:bg-yellow-900/30 p-3">
<p class="text-xs text-yellow-800 dark:text-yellow-300" v-html="t('admin.accounts.oauth.proxyWarning')">
</p>
</div>
</div>
</div>
</div>
<!-- Step 3: Enter authorization code -->
<div class="rounded-lg border border-blue-300 dark:border-blue-600 bg-white/80 dark:bg-gray-800/80 p-4">
<div class="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">
3
</div>
<div class="flex-1">
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
{{ t('admin.accounts.oauth.step3EnterCode') }}
</p>
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300" v-html="t('admin.accounts.oauth.authCodeDesc')">
</p>
<div>
<label class="input-label">
<svg class="w-4 h-4 inline mr-1 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
</svg>
{{ t('admin.accounts.oauth.authCode') }}
</label>
<textarea
v-model="authCodeInput"
rows="3"
class="input w-full font-mono text-sm resize-none"
:placeholder="t('admin.accounts.oauth.authCodePlaceholder')"
></textarea>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<svg class="w-3 h-3 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
</svg>
{{ t('admin.accounts.oauth.authCodeHint') }}
</p>
</div>
<!-- Error Message -->
<div
v-if="error"
class="mt-3 rounded-lg border border-red-200 bg-red-50 dark:border-red-700 dark:bg-red-900/30 p-3"
>
<p class="text-sm text-red-600 dark:text-red-400 whitespace-pre-line">
{{ error }}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useClipboard } from '@/composables/useClipboard'
import type { AddMethod, AuthInputMethod } from '@/composables/useAccountOAuth'
interface Props {
addMethod: AddMethod
authUrl?: string
sessionId?: string
loading?: boolean
error?: string
showHelp?: boolean
showProxyWarning?: boolean
allowMultiple?: boolean
methodLabel?: string
}
const props = withDefaults(defineProps<Props>(), {
authUrl: '',
sessionId: '',
loading: false,
error: '',
showHelp: true,
showProxyWarning: true,
allowMultiple: false,
methodLabel: 'Authorization Method'
})
const emit = defineEmits<{
'generate-url': []
'exchange-code': [code: string]
'cookie-auth': [sessionKey: string]
'update:inputMethod': [method: AuthInputMethod]
}>()
const { t } = useI18n()
// Local state
const inputMethod = ref<AuthInputMethod>('manual')
const authCodeInput = ref('')
const sessionKeyInput = ref('')
const showHelpDialog = ref(false)
// Clipboard
const { copied, copyToClipboard } = useClipboard()
// Computed
const parsedKeyCount = computed(() => {
return sessionKeyInput.value.split('\n').map(k => k.trim()).filter(k => k).length
})
// Watchers
watch(inputMethod, (newVal) => {
emit('update:inputMethod', newVal)
})
// Methods
const handleGenerateUrl = () => {
emit('generate-url')
}
const handleCopyUrl = () => {
if (props.authUrl) {
copyToClipboard(props.authUrl, 'URL copied to clipboard')
}
}
const handleRegenerate = () => {
authCodeInput.value = ''
emit('generate-url')
}
const handleCookieAuth = () => {
if (sessionKeyInput.value.trim()) {
emit('cookie-auth', sessionKeyInput.value)
}
}
// Expose methods and state
defineExpose({
authCode: authCodeInput,
sessionKey: sessionKeyInput,
inputMethod,
reset: () => {
authCodeInput.value = ''
sessionKeyInput.value = ''
inputMethod.value = 'manual'
showHelpDialog.value = false
}
})
</script>

View File

@@ -0,0 +1,240 @@
<template>
<Modal
:show="show"
:title="t('admin.accounts.reAuthorizeAccount')"
size="lg"
@close="handleClose"
>
<div v-if="account" class="space-y-5">
<!-- Account Info -->
<div class="rounded-lg border border-gray-200 dark:border-dark-600 bg-gray-50 dark:bg-dark-700 p-4">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-orange-500 to-orange-600">
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
</svg>
</div>
<div>
<span class="block font-semibold text-gray-900 dark:text-white">{{ account.name }}</span>
<span class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.accounts.claudeCodeAccount') }}</span>
</div>
</div>
</div>
<!-- Add Method Selection -->
<div>
<label class="input-label">{{ t('admin.accounts.oauth.authMethod') }}</label>
<div class="flex gap-4 mt-2">
<label class="flex cursor-pointer items-center">
<input
v-model="addMethod"
type="radio"
value="oauth"
class="mr-2 text-primary-600 focus:ring-primary-500"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Oauth</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="addMethod"
type="radio"
value="setup-token"
class="mr-2 text-primary-600 focus:ring-primary-500"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.accounts.setupTokenLongLived') }}</span>
</label>
</div>
</div>
<!-- OAuth Authorization Section -->
<OAuthAuthorizationFlow
ref="oauthFlowRef"
:add-method="addMethod"
:auth-url="oauth.authUrl.value"
:session-id="oauth.sessionId.value"
:loading="oauth.loading.value"
:error="oauth.error.value"
:show-help="false"
:show-proxy-warning="false"
:allow-multiple="false"
:method-label="t('admin.accounts.inputMethod')"
@generate-url="handleGenerateUrl"
@cookie-auth="handleCookieAuth"
/>
<div class="flex justify-between gap-3 pt-4">
<button
type="button"
class="btn btn-secondary"
@click="handleClose"
>
{{ t('common.cancel') }}
</button>
<button
v-if="oauthFlowRef?.inputMethod?.value === 'manual'"
type="button"
:disabled="!canExchangeCode"
class="btn btn-primary"
@click="handleExchangeCode"
>
<svg
v-if="oauth.loading.value"
class="animate-spin -ml-1 mr-2 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ oauth.loading.value ? t('admin.accounts.oauth.verifying') : t('admin.accounts.oauth.completeAuth') }}
</button>
</div>
</div>
</Modal>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import { useAccountOAuth, type AddMethod } from '@/composables/useAccountOAuth'
import type { Account } from '@/types'
import Modal from '@/components/common/Modal.vue'
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
interface Props {
show: boolean
account: Account | null
}
const props = defineProps<Props>()
const emit = defineEmits<{
close: []
reauthorized: []
}>()
const appStore = useAppStore()
const { t } = useI18n()
// OAuth composable
const oauth = useAccountOAuth()
// Refs
const oauthFlowRef = ref<InstanceType<typeof OAuthAuthorizationFlow> | null>(null)
// State
const addMethod = ref<AddMethod>('oauth')
// Computed
const canExchangeCode = computed(() => {
const authCode = oauthFlowRef.value?.authCode?.value || ''
return authCode.trim() && oauth.sessionId.value && !oauth.loading.value
})
// Watchers
watch(() => props.show, (newVal) => {
if (newVal && props.account) {
// Initialize addMethod based on current account type
if (props.account.type === 'oauth' || props.account.type === 'setup-token') {
addMethod.value = props.account.type as AddMethod
}
} else {
resetState()
}
})
// Methods
const resetState = () => {
addMethod.value = 'oauth'
oauth.resetState()
oauthFlowRef.value?.reset()
}
const handleClose = () => {
emit('close')
}
const handleGenerateUrl = async () => {
if (!props.account) return
await oauth.generateAuthUrl(addMethod.value, props.account.proxy_id)
}
const handleExchangeCode = async () => {
if (!props.account) return
const authCode = oauthFlowRef.value?.authCode?.value || ''
if (!authCode.trim() || !oauth.sessionId.value) return
oauth.loading.value = true
oauth.error.value = ''
try {
const proxyConfig = props.account.proxy_id ? { proxy_id: props.account.proxy_id } : {}
const endpoint = addMethod.value === 'oauth'
? '/admin/accounts/exchange-code'
: '/admin/accounts/exchange-setup-token-code'
const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, {
session_id: oauth.sessionId.value,
code: authCode.trim(),
...proxyConfig
})
const extra = oauth.buildExtraInfo(tokenInfo)
// Update account with new credentials and type
await adminAPI.accounts.update(props.account.id, {
type: addMethod.value, // Update type based on selected method
credentials: tokenInfo,
extra
})
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
emit('reauthorized')
handleClose()
} catch (error: any) {
oauth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(oauth.error.value)
} finally {
oauth.loading.value = false
}
}
const handleCookieAuth = async (sessionKey: string) => {
if (!props.account) return
oauth.loading.value = true
oauth.error.value = ''
try {
const proxyConfig = props.account.proxy_id ? { proxy_id: props.account.proxy_id } : {}
const endpoint = addMethod.value === 'oauth'
? '/admin/accounts/cookie-auth'
: '/admin/accounts/setup-token-cookie-auth'
const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, {
session_id: '',
code: sessionKey.trim(),
...proxyConfig
})
const extra = oauth.buildExtraInfo(tokenInfo)
// Update account with new credentials and type
await adminAPI.accounts.update(props.account.id, {
type: addMethod.value, // Update type based on selected method
credentials: tokenInfo,
extra
})
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
emit('reauthorized')
handleClose()
} catch (error: any) {
oauth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.cookieAuthFailed')
} finally {
oauth.loading.value = false
}
}
</script>

View File

@@ -0,0 +1,200 @@
<template>
<div class="space-y-1">
<!-- 5h Time Window Progress -->
<div v-if="hasWindowInfo" class="flex items-center gap-1">
<!-- Label badge -->
<span class="text-[10px] font-medium px-1 rounded w-[32px] text-center shrink-0 bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300">
5h
</span>
<!-- Progress bar container -->
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden shrink-0">
<div
:class="['h-full transition-all duration-300', barColorClass]"
:style="{ width: progressWidth }"
></div>
</div>
<!-- Percentage -->
<span :class="['text-[10px] font-medium w-[32px] text-right shrink-0', textColorClass]">
{{ displayPercent }}
</span>
<!-- Reset time -->
<span class="text-[10px] text-gray-400 shrink-0">
{{ formatResetTime }}
</span>
</div>
<!-- No recent activity (had activity but window expired > 1 hour) -->
<div v-else-if="hasExpiredWindow" class="flex items-center gap-1">
<span class="text-[10px] font-medium px-1 rounded w-[32px] text-center shrink-0 bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300">
5h
</span>
<span class="text-[10px] text-gray-400 italic">
No recent activity
</span>
</div>
<!-- No window info yet (never had activity) -->
<div v-else class="flex items-center gap-1">
<span class="text-[10px] font-medium px-1 rounded w-[32px] text-center shrink-0 bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300">
5h
</span>
<span class="text-[10px] text-gray-400 italic">
No activity yet
</span>
</div>
<!-- Hint -->
<div class="text-[10px] text-gray-400 italic">
Setup Token (time-based)
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue'
import type { Account } from '@/types'
const props = defineProps<{
account: Account
}>()
// Update timer
const currentTime = ref(new Date())
let timer: ReturnType<typeof setInterval> | null = null
onMounted(() => {
// Update every second for more accurate countdown
timer = setInterval(() => {
currentTime.value = new Date()
}, 1000)
})
onUnmounted(() => {
if (timer) {
clearInterval(timer)
}
})
// Check if we have window information but it's been expired for more than 1 hour
const hasExpiredWindow = computed(() => {
if (!props.account.session_window_start || !props.account.session_window_end) {
return false
}
const end = new Date(props.account.session_window_end).getTime()
const now = currentTime.value.getTime()
const expiredMs = now - end
// Window exists and expired more than 1 hour ago
return expiredMs > 1000 * 60 * 60
})
// Check if we have valid window information (not expired for more than 1 hour)
const hasWindowInfo = computed(() => {
if (!props.account.session_window_start || !props.account.session_window_end) {
return false
}
// If window is expired more than 1 hour, don't show progress bar
if (hasExpiredWindow.value) {
return false
}
return true
})
// Calculate time-based progress (0-100)
const timeProgress = computed(() => {
if (!props.account.session_window_start || !props.account.session_window_end) {
return 0
}
const start = new Date(props.account.session_window_start).getTime()
const end = new Date(props.account.session_window_end).getTime()
const now = currentTime.value.getTime()
// Window hasn't started yet
if (now < start) {
return 0
}
// Window has ended
if (now >= end) {
return 100
}
// Calculate progress within window
const total = end - start
const elapsed = now - start
return Math.round((elapsed / total) * 100)
})
// Progress bar width
const progressWidth = computed(() => {
return `${Math.min(timeProgress.value, 100)}%`
})
// Display percentage
const displayPercent = computed(() => {
return `${timeProgress.value}%`
})
// Progress bar color based on progress
const barColorClass = computed(() => {
if (timeProgress.value >= 100) {
return 'bg-red-500'
} else if (timeProgress.value >= 80) {
return 'bg-amber-500'
} else {
return 'bg-green-500'
}
})
// Text color based on progress
const textColorClass = computed(() => {
if (timeProgress.value >= 100) {
return 'text-red-600 dark:text-red-400'
} else if (timeProgress.value >= 80) {
return 'text-amber-600 dark:text-amber-400'
} else {
return 'text-gray-600 dark:text-gray-400'
}
})
// Format reset time (time remaining until window end)
const formatResetTime = computed(() => {
if (!props.account.session_window_end) {
return 'N/A'
}
const end = new Date(props.account.session_window_end)
const now = currentTime.value
const diffMs = end.getTime() - now.getTime()
if (diffMs <= 0) {
// 窗口已过期,计算过期了多久
const expiredMs = Math.abs(diffMs)
const expiredHours = Math.floor(expiredMs / (1000 * 60 * 60))
if (expiredHours >= 1) {
return 'No recent activity'
}
return 'Window expired'
}
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60))
const diffSecs = Math.floor((diffMs % (1000 * 60)) / 1000)
if (diffHours > 0) {
return `${diffHours}h ${diffMins}m`
} else if (diffMins > 0) {
return `${diffMins}m ${diffSecs}s`
} else {
return `${diffSecs}s`
}
})
</script>

View File

@@ -0,0 +1,129 @@
<template>
<div class="flex items-center gap-1">
<!-- Label badge (fixed width for alignment) -->
<span
:class="[
'text-[10px] font-medium px-1 rounded w-[32px] text-center shrink-0',
labelClass
]"
>
{{ label }}
</span>
<!-- Progress bar container -->
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden shrink-0">
<div
:class="['h-full transition-all duration-300', barClass]"
:style="{ width: barWidth }"
></div>
</div>
<!-- Percentage -->
<span :class="['text-[10px] font-medium w-[32px] text-right shrink-0', textClass]">
{{ displayPercent }}
</span>
<!-- Reset time -->
<span v-if="resetsAt" class="text-[10px] text-gray-400 shrink-0">
{{ formatResetTime }}
</span>
<!-- Window stats (only for 5h window) -->
<span v-if="windowStats" class="text-[10px] text-gray-400 shrink-0 ml-1">
({{ formatStats }})
</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { WindowStats } from '@/types'
const props = defineProps<{
label: string
utilization: number // Percentage (0-100+)
resetsAt?: string | null
color: 'indigo' | 'emerald' | 'purple'
windowStats?: WindowStats | null
}>()
// Label background colors
const labelClass = computed(() => {
const colors = {
indigo: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300',
emerald: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
purple: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300'
}
return colors[props.color]
})
// Progress bar color based on utilization
const barClass = computed(() => {
if (props.utilization >= 100) {
return 'bg-red-500'
} else if (props.utilization >= 80) {
return 'bg-amber-500'
} else {
return 'bg-green-500'
}
})
// Text color based on utilization
const textClass = computed(() => {
if (props.utilization >= 100) {
return 'text-red-600 dark:text-red-400'
} else if (props.utilization >= 80) {
return 'text-amber-600 dark:text-amber-400'
} else {
return 'text-gray-600 dark:text-gray-400'
}
})
// Bar width (capped at 100%)
const barWidth = computed(() => {
return `${Math.min(props.utilization, 100)}%`
})
// Display percentage (cap at 999% for readability)
const displayPercent = computed(() => {
const percent = Math.round(props.utilization)
return percent > 999 ? '>999%' : `${percent}%`
})
// Format reset time
const formatResetTime = computed(() => {
if (!props.resetsAt) return 'N/A'
const date = new Date(props.resetsAt)
const now = new Date()
const diffMs = date.getTime() - now.getTime()
if (diffMs <= 0) return 'Now'
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60))
if (diffHours >= 24) {
const days = Math.floor(diffHours / 24)
return `${days}d ${diffHours % 24}h`
} else if (diffHours > 0) {
return `${diffHours}h ${diffMins}m`
} else {
return `${diffMins}m`
}
})
// Format window stats
const formatStats = computed(() => {
if (!props.windowStats) return ''
const { requests, tokens, cost } = props.windowStats
// Format tokens (e.g., 1234567 -> 1.2M)
const formatTokens = (t: number): string => {
if (t >= 1000000) return `${(t / 1000000).toFixed(1)}M`
if (t >= 1000) return `${(t / 1000).toFixed(1)}K`
return t.toString()
}
return `${requests}req ${formatTokens(tokens)}tok $${cost.toFixed(2)}`
})
</script>

View File

@@ -0,0 +1,7 @@
export { default as CreateAccountModal } from './CreateAccountModal.vue'
export { default as EditAccountModal } from './EditAccountModal.vue'
export { default as ReAuthAccountModal } from './ReAuthAccountModal.vue'
export { default as OAuthAuthorizationFlow } from './OAuthAuthorizationFlow.vue'
export { default as AccountStatusIndicator } from './AccountStatusIndicator.vue'
export { default as AccountUsageCell } from './AccountUsageCell.vue'
export { default as UsageProgressBar } from './UsageProgressBar.vue'