Commit 5deef27e authored by ianshaw's avatar ianshaw
Browse files

style(frontend): 优化 Components 代码风格和结构

- 统一移除语句末尾分号,规范代码格式
- 优化组件类型定义和 props 声明
- 改进组件文档和示例代码
- 提升代码可读性和一致性
parent 1ac8b1f0
...@@ -5,158 +5,164 @@ ...@@ -5,158 +5,164 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'; import { ref, onMounted, onUnmounted, watch } from 'vue'
interface TurnstileRenderOptions { interface TurnstileRenderOptions {
sitekey: string; sitekey: string
callback: (token: string) => void; callback: (token: string) => void
'expired-callback'?: () => void; 'expired-callback'?: () => void
'error-callback'?: () => void; 'error-callback'?: () => void
theme?: 'light' | 'dark' | 'auto'; theme?: 'light' | 'dark' | 'auto'
size?: 'normal' | 'compact' | 'flexible'; size?: 'normal' | 'compact' | 'flexible'
} }
interface TurnstileAPI { interface TurnstileAPI {
render: (container: HTMLElement, options: TurnstileRenderOptions) => string; render: (container: HTMLElement, options: TurnstileRenderOptions) => string
reset: (widgetId?: string) => void; reset: (widgetId?: string) => void
remove: (widgetId?: string) => void; remove: (widgetId?: string) => void
} }
declare global { declare global {
interface Window { interface Window {
turnstile?: TurnstileAPI; turnstile?: TurnstileAPI
onTurnstileLoad?: () => void; onTurnstileLoad?: () => void
} }
} }
const props = withDefaults(defineProps<{ const props = withDefaults(
siteKey: string; defineProps<{
theme?: 'light' | 'dark' | 'auto'; siteKey: string
size?: 'normal' | 'compact' | 'flexible'; theme?: 'light' | 'dark' | 'auto'
}>(), { size?: 'normal' | 'compact' | 'flexible'
theme: 'auto', }>(),
size: 'flexible', {
}); theme: 'auto',
size: 'flexible'
}
)
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'verify', token: string): void; (e: 'verify', token: string): void
(e: 'expire'): void; (e: 'expire'): void
(e: 'error'): void; (e: 'error'): void
}>(); }>()
const containerRef = ref<HTMLElement | null>(null); const containerRef = ref<HTMLElement | null>(null)
const widgetId = ref<string | null>(null); const widgetId = ref<string | null>(null)
const scriptLoaded = ref(false); const scriptLoaded = ref(false)
const loadScript = (): Promise<void> => { const loadScript = (): Promise<void> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (window.turnstile) { if (window.turnstile) {
scriptLoaded.value = true; scriptLoaded.value = true
resolve(); resolve()
return; return
} }
// Check if script is already loading // Check if script is already loading
const existingScript = document.querySelector('script[src*="turnstile"]'); const existingScript = document.querySelector('script[src*="turnstile"]')
if (existingScript) { if (existingScript) {
window.onTurnstileLoad = () => { window.onTurnstileLoad = () => {
scriptLoaded.value = true; scriptLoaded.value = true
resolve(); resolve()
}; }
return; return
} }
const script = document.createElement('script'); const script = document.createElement('script')
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad'; script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad'
script.async = true; script.async = true
script.defer = true; script.defer = true
window.onTurnstileLoad = () => { window.onTurnstileLoad = () => {
scriptLoaded.value = true; scriptLoaded.value = true
resolve(); resolve()
}; }
script.onerror = () => { script.onerror = () => {
reject(new Error('Failed to load Turnstile script')); reject(new Error('Failed to load Turnstile script'))
}; }
document.head.appendChild(script); document.head.appendChild(script)
}); })
}; }
const renderWidget = () => { const renderWidget = () => {
if (!window.turnstile || !containerRef.value || !props.siteKey) { if (!window.turnstile || !containerRef.value || !props.siteKey) {
return; return
} }
// Remove existing widget if any // Remove existing widget if any
if (widgetId.value) { if (widgetId.value) {
try { try {
window.turnstile.remove(widgetId.value); window.turnstile.remove(widgetId.value)
} catch { } catch {
// Ignore errors when removing // Ignore errors when removing
} }
widgetId.value = null; widgetId.value = null
} }
// Clear container // Clear container
containerRef.value.innerHTML = ''; containerRef.value.innerHTML = ''
widgetId.value = window.turnstile.render(containerRef.value, { widgetId.value = window.turnstile.render(containerRef.value, {
sitekey: props.siteKey, sitekey: props.siteKey,
callback: (token: string) => { callback: (token: string) => {
emit('verify', token); emit('verify', token)
}, },
'expired-callback': () => { 'expired-callback': () => {
emit('expire'); emit('expire')
}, },
'error-callback': () => { 'error-callback': () => {
emit('error'); emit('error')
}, },
theme: props.theme, theme: props.theme,
size: props.size, size: props.size
}); })
}; }
const reset = () => { const reset = () => {
if (window.turnstile && widgetId.value) { if (window.turnstile && widgetId.value) {
window.turnstile.reset(widgetId.value); window.turnstile.reset(widgetId.value)
} }
}; }
// Expose reset method to parent // Expose reset method to parent
defineExpose({ reset }); defineExpose({ reset })
onMounted(async () => { onMounted(async () => {
if (!props.siteKey) { if (!props.siteKey) {
return; return
} }
try { try {
await loadScript(); await loadScript()
renderWidget(); renderWidget()
} catch (error) { } catch (error) {
console.error('Failed to initialize Turnstile:', error); console.error('Failed to initialize Turnstile:', error)
emit('error'); emit('error')
} }
}); })
onUnmounted(() => { onUnmounted(() => {
if (window.turnstile && widgetId.value) { if (window.turnstile && widgetId.value) {
try { try {
window.turnstile.remove(widgetId.value); window.turnstile.remove(widgetId.value)
} catch { } catch {
// Ignore errors when removing // Ignore errors when removing
} }
} }
}); })
// Re-render when siteKey changes // Re-render when siteKey changes
watch(() => props.siteKey, (newKey) => { watch(
if (newKey && scriptLoaded.value) { () => props.siteKey,
renderWidget(); (newKey) => {
if (newKey && scriptLoaded.value) {
renderWidget()
}
} }
}); )
</script> </script>
<style scoped> <style scoped>
......
<template> <template>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<!-- Main Status Badge --> <!-- Main Status Badge -->
<span <span :class="['badge text-xs', statusClass]">
:class="[
'badge text-xs',
statusClass
]"
>
{{ statusText }} {{ statusText }}
</span> </span>
<!-- Error Info Indicator --> <!-- Error Info Indicator -->
<div v-if="hasError && account.error_message" class="relative group/error"> <div v-if="hasError && account.error_message" class="group/error relative">
<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"> <svg
<path stroke-linecap="round" stroke-linejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" /> class="h-4 w-4 cursor-help text-red-500 transition-colors hover:text-red-600 dark:text-red-400 dark:hover:text-red-300"
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> </svg>
<!-- Tooltip - 向下显示 --> <!-- 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
<div class="text-gray-300 break-words whitespace-pre-wrap leading-relaxed">{{ account.error_message }}</div> class="invisible absolute left-0 top-full z-[100] mt-1.5 min-w-[200px] max-w-[300px] rounded-lg bg-gray-800 px-3 py-2 text-xs text-white opacity-0 shadow-xl transition-all duration-200 group-hover/error:visible group-hover/error:opacity-100 dark:bg-gray-900"
>
<div class="whitespace-pre-wrap break-words leading-relaxed text-gray-300">
{{ 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
class="absolute bottom-full left-3 border-[6px] border-transparent border-b-gray-800 dark:border-b-gray-900"
></div>
</div> </div>
</div> </div>
<!-- Rate Limit Indicator (429) --> <!-- Rate Limit Indicator (429) -->
<div v-if="isRateLimited" class="relative group"> <div v-if="isRateLimited" class="group relative">
<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"> <span
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> class="inline-flex items-center gap-1 rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
<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 class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg> </svg>
429 429
</span> </span>
<!-- Tooltip --> <!-- 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"> <div
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
>
Rate limited until {{ formatTime(account.rate_limit_reset_at) }} 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
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
></div>
</div> </div>
</div> </div>
<!-- Overload Indicator (529) --> <!-- Overload Indicator (529) -->
<div v-if="isOverloaded" class="relative group"> <div v-if="isOverloaded" class="group relative">
<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"> <span
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> class="inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
<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 class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg> </svg>
529 529
</span> </span>
<!-- Tooltip --> <!-- 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"> <div
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
>
Overloaded until {{ formatTime(account.overload_until) }} 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
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
></div>
</div> </div>
</div> </div>
</div> </div>
......
...@@ -7,17 +7,29 @@ ...@@ -7,17 +7,29 @@
> >
<div class="space-y-4"> <div class="space-y-4">
<!-- Account Info Card --> <!-- 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
v-if="account"
class="flex items-center justify-between rounded-xl border border-gray-200 bg-gradient-to-r from-gray-50 to-gray-100 p-3 dark:border-dark-500 dark:from-dark-700 dark:to-dark-600"
>
<div class="flex items-center gap-3"> <div class="flex 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"> <div
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"> class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-primary-600"
<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 class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg> </svg>
</div> </div>
<div> <div>
<div class="font-semibold text-gray-900 dark:text-gray-100">{{ account.name }}</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"> <div class="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<span class="px-1.5 py-0.5 bg-gray-200 dark:bg-dark-500 rounded text-[10px] font-medium uppercase"> <span
class="rounded bg-gray-200 px-1.5 py-0.5 text-[10px] font-medium uppercase dark:bg-dark-500"
>
{{ account.type }} {{ account.type }}
</span> </span>
<span>{{ t('admin.accounts.account') }}</span> <span>{{ t('admin.accounts.account') }}</span>
...@@ -26,7 +38,7 @@ ...@@ -26,7 +38,7 @@
</div> </div>
<span <span
:class="[ :class="[
'px-2.5 py-1 text-xs font-semibold rounded-full', 'rounded-full px-2.5 py-1 text-xs font-semibold',
account.status === 'active' account.status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400' ? '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' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
...@@ -44,7 +56,7 @@ ...@@ -44,7 +56,7 @@
<select <select
v-model="selectedModelId" v-model="selectedModelId"
:disabled="loadingModels || status === 'connecting'" :disabled="loadingModels || status === 'connecting'"
class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-dark-500 bg-white dark:bg-dark-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50 disabled:cursor-not-allowed" class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50 dark:border-dark-500 dark:bg-dark-700 dark:text-gray-100"
> >
<option v-if="loadingModels" value="">{{ t('common.loading') }}...</option> <option v-if="loadingModels" value="">{{ t('common.loading') }}...</option>
<option v-for="model in availableModels" :key="model.id" :value="model.id"> <option v-for="model in availableModels" :key="model.id" :value="model.id">
...@@ -54,22 +66,38 @@ ...@@ -54,22 +66,38 @@
</div> </div>
<!-- Terminal Output --> <!-- Terminal Output -->
<div class="relative group"> <div class="group relative">
<div <div
ref="terminalRef" 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" class="max-h-[240px] min-h-[120px] overflow-y-auto rounded-xl border border-gray-700 bg-gray-900 p-4 font-mono text-sm dark:border-gray-800 dark:bg-black"
> >
<!-- Status Line --> <!-- Status Line -->
<div v-if="status === 'idle'" class="text-gray-500 flex items-center gap-2"> <div v-if="status === 'idle'" class="flex items-center gap-2 text-gray-500">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg> </svg>
<span>{{ t('admin.accounts.readyToTest') }}</span> <span>{{ t('admin.accounts.readyToTest') }}</span>
</div> </div>
<div v-else-if="status === 'connecting'" class="text-yellow-400 flex items-center gap-2"> <div v-else-if="status === 'connecting'" class="flex items-center gap-2 text-yellow-400">
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24"> <svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <circle
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg> </svg>
<span>{{ t('admin.accounts.connectingToApi') }}</span> <span>{{ t('admin.accounts.connectingToApi') }}</span>
</div> </div>
...@@ -85,15 +113,31 @@ ...@@ -85,15 +113,31 @@
</div> </div>
<!-- Result Status --> <!-- 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"> <div
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> v-if="status === 'success'"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> class="mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-green-400"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg> </svg>
<span>{{ t('admin.accounts.testCompleted') }}</span> <span>{{ t('admin.accounts.testCompleted') }}</span>
</div> </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"> <div
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> v-else-if="status === 'error'"
<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" /> class="mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-red-400"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg> </svg>
<span>{{ errorMessage }}</span> <span>{{ errorMessage }}</span>
</div> </div>
...@@ -103,28 +147,43 @@ ...@@ -103,28 +147,43 @@
<button <button
v-if="outputLines.length > 0" v-if="outputLines.length > 0"
@click="copyOutput" @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" class="absolute right-2 top-2 rounded-lg bg-gray-800/80 p-1.5 text-gray-400 opacity-0 transition-all hover:bg-gray-700 hover:text-white group-hover:opacity-100"
:title="t('admin.accounts.copyOutput')" :title="t('admin.accounts.copyOutput')"
> >
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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" /> <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> </svg>
</button> </button>
</div> </div>
<!-- Test Info --> <!-- 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 justify-between px-1 text-xs text-gray-500 dark:text-gray-400">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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" /> <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> </svg>
{{ t('admin.accounts.testModel') }} {{ t('admin.accounts.testModel') }}
</span> </span>
</div> </div>
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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" /> <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> </svg>
{{ t('admin.accounts.testPrompt') }} {{ t('admin.accounts.testPrompt') }}
</span> </span>
...@@ -135,7 +194,7 @@ ...@@ -135,7 +194,7 @@
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">
<button <button
@click="handleClose" @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" class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
:disabled="status === 'connecting'" :disabled="status === 'connecting'"
> >
{{ t('common.close') }} {{ t('common.close') }}
...@@ -144,29 +203,72 @@ ...@@ -144,29 +203,72 @@
@click="startTest" @click="startTest"
:disabled="status === 'connecting' || !selectedModelId" :disabled="status === 'connecting' || !selectedModelId"
:class="[ :class="[
'px-4 py-2 text-sm font-medium rounded-lg transition-all flex items-center gap-2', 'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all',
status === 'connecting' || !selectedModelId status === 'connecting' || !selectedModelId
? 'bg-primary-400 text-white cursor-not-allowed' ? 'cursor-not-allowed bg-primary-400 text-white'
: status === 'success' : status === 'success'
? 'bg-green-500 hover:bg-green-600 text-white' ? 'bg-green-500 text-white hover:bg-green-600'
: status === 'error' : status === 'error'
? 'bg-orange-500 hover:bg-orange-600 text-white' ? 'bg-orange-500 text-white hover:bg-orange-600'
: 'bg-primary-500 hover:bg-primary-600 text-white' : 'bg-primary-500 text-white hover:bg-primary-600'
]" ]"
> >
<svg v-if="status === 'connecting'" class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"> <svg
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> v-if="status === 'connecting'"
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> class="h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg> </svg>
<svg v-else-if="status === 'idle'" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
<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" /> v-else-if="status === 'idle'"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg> </svg>
<svg v-else class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg v-else class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> <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> </svg>
<span> <span>
{{ status === 'connecting' ? t('admin.accounts.testing') : status === 'idle' ? t('admin.accounts.startTest') : t('admin.accounts.retry') }} {{
status === 'connecting'
? t('admin.accounts.testing')
: status === 'idle'
? t('admin.accounts.startTest')
: t('admin.accounts.retry')
}}
</span> </span>
</button> </button>
</div> </div>
...@@ -208,14 +310,17 @@ const loadingModels = ref(false) ...@@ -208,14 +310,17 @@ const loadingModels = ref(false)
let eventSource: EventSource | null = null let eventSource: EventSource | null = null
// Load available models when modal opens // Load available models when modal opens
watch(() => props.show, async (newVal) => { watch(
if (newVal && props.account) { () => props.show,
resetState() async (newVal) => {
await loadAvailableModels() if (newVal && props.account) {
} else { resetState()
closeEventSource() await loadAvailableModels()
} else {
closeEventSource()
}
} }
}) )
const loadAvailableModels = async () => { const loadAvailableModels = async () => {
if (!props.account) return if (!props.account) return
...@@ -227,7 +332,7 @@ const loadAvailableModels = async () => { ...@@ -227,7 +332,7 @@ const loadAvailableModels = async () => {
// Default to first model (usually Sonnet) // Default to first model (usually Sonnet)
if (availableModels.value.length > 0) { if (availableModels.value.length > 0) {
// Try to select Sonnet as default, otherwise use first model // Try to select Sonnet as default, otherwise use first model
const sonnetModel = availableModels.value.find(m => m.id.includes('sonnet')) const sonnetModel = availableModels.value.find((m) => m.id.includes('sonnet'))
selectedModelId.value = sonnetModel?.id || availableModels.value[0].id selectedModelId.value = sonnetModel?.id || availableModels.value[0].id
} }
} catch (error) { } catch (error) {
...@@ -290,7 +395,7 @@ const startTest = async () => { ...@@ -290,7 +395,7 @@ const startTest = async () => {
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`, Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ model_id: selectedModelId.value }) body: JSON.stringify({ model_id: selectedModelId.value })
...@@ -337,7 +442,13 @@ const startTest = async () => { ...@@ -337,7 +442,13 @@ const startTest = async () => {
} }
} }
const handleEvent = (event: { type: string; text?: string; model?: string; success?: boolean; error?: string }) => { const handleEvent = (event: {
type: string
text?: string
model?: string
success?: boolean
error?: string
}) => {
switch (event.type) { switch (event.type) {
case 'test_start': case 'test_start':
addLine(t('admin.accounts.connectedToApi'), 'text-green-400') addLine(t('admin.accounts.connectedToApi'), 'text-green-400')
...@@ -382,7 +493,7 @@ const handleEvent = (event: { type: string; text?: string; model?: string; succe ...@@ -382,7 +493,7 @@ const handleEvent = (event: { type: string; text?: string; model?: string; succe
} }
const copyOutput = () => { const copyOutput = () => {
const text = outputLines.value.map(l => l.text).join('\n') const text = outputLines.value.map((l) => l.text).join('\n')
navigator.clipboard.writeText(text) navigator.clipboard.writeText(text)
} }
</script> </script>
...@@ -2,9 +2,9 @@ ...@@ -2,9 +2,9 @@
<div> <div>
<!-- Loading state --> <!-- Loading state -->
<div v-if="loading" class="space-y-0.5"> <div v-if="loading" class="space-y-0.5">
<div class="h-3 w-12 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div> <div class="h-3 w-12 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="h-3 w-16 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div> <div class="h-3 w-16 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div> <div class="h-3 w-10 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
</div> </div>
<!-- Error state --> <!-- Error state -->
...@@ -17,24 +17,28 @@ ...@@ -17,24 +17,28 @@
<!-- Requests --> <!-- Requests -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400">Req:</span> <span class="text-gray-500 dark:text-gray-400">Req:</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ formatNumber(stats.requests) }}</span> <span class="font-medium text-gray-700 dark:text-gray-300">{{
formatNumber(stats.requests)
}}</span>
</div> </div>
<!-- Tokens --> <!-- Tokens -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400">Tok:</span> <span class="text-gray-500 dark:text-gray-400">Tok:</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ formatTokens(stats.tokens) }}</span> <span class="font-medium text-gray-700 dark:text-gray-300">{{
formatTokens(stats.tokens)
}}</span>
</div> </div>
<!-- Cost --> <!-- Cost -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400">Cost:</span> <span class="text-gray-500 dark:text-gray-400">Cost:</span>
<span class="font-medium text-emerald-600 dark:text-emerald-400">{{ formatCurrency(stats.cost) }}</span> <span class="font-medium text-emerald-600 dark:text-emerald-400">{{
formatCurrency(stats.cost)
}}</span>
</div> </div>
</div> </div>
<!-- No data --> <!-- No data -->
<div v-else class="text-xs text-gray-400"> <div v-else class="text-xs text-gray-400">-</div>
-
</div>
</div> </div>
</template> </template>
......
<template> <template>
<div v-if="showUsageWindows"> <div v-if="showUsageWindows">
<!-- Anthropic OAuth and Setup Token accounts: fetch real usage data --> <!-- Anthropic OAuth and Setup Token accounts: fetch real usage data -->
<template v-if="account.platform === 'anthropic' && (account.type === 'oauth' || account.type === 'setup-token')"> <template
v-if="
account.platform === 'anthropic' &&
(account.type === 'oauth' || account.type === 'setup-token')
"
>
<!-- Loading state --> <!-- Loading state -->
<div v-if="loading" class="space-y-1.5"> <div v-if="loading" class="space-y-1.5">
<!-- OAuth: 3 rows, Setup Token: 1 row --> <!-- OAuth: 3 rows, Setup Token: 1 row -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div> <div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse"></div> <div class="h-1.5 w-8 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700"></div>
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div> <div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
</div> </div>
<template v-if="account.type === 'oauth'"> <template v-if="account.type === 'oauth'">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div> <div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse"></div> <div class="h-1.5 w-8 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700"></div>
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div> <div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
</div> </div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div> <div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse"></div> <div class="h-1.5 w-8 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700"></div>
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div> <div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
</div> </div>
</template> </template>
</div> </div>
...@@ -61,9 +66,7 @@ ...@@ -61,9 +66,7 @@
</div> </div>
<!-- No data yet --> <!-- No data yet -->
<div v-else class="text-xs text-gray-400"> <div v-else class="text-xs text-gray-400">-</div>
-
</div>
</template> </template>
<!-- OpenAI OAuth accounts: show Codex usage from extra field --> <!-- OpenAI OAuth accounts: show Codex usage from extra field -->
...@@ -97,9 +100,7 @@ ...@@ -97,9 +100,7 @@
</div> </div>
<!-- Non-OAuth/Setup-Token accounts --> <!-- Non-OAuth/Setup-Token accounts -->
<div v-else class="text-xs text-gray-400"> <div v-else class="text-xs text-gray-400">-</div>
-
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
...@@ -117,20 +118,21 @@ const error = ref<string | null>(null) ...@@ -117,20 +118,21 @@ const error = ref<string | null>(null)
const usageInfo = ref<AccountUsageInfo | null>(null) const usageInfo = ref<AccountUsageInfo | null>(null)
// Show usage windows for OAuth and Setup Token accounts // Show usage windows for OAuth and Setup Token accounts
const showUsageWindows = computed(() => const showUsageWindows = computed(
props.account.type === 'oauth' || props.account.type === 'setup-token' () => props.account.type === 'oauth' || props.account.type === 'setup-token'
) )
// OpenAI Codex usage computed properties // OpenAI Codex usage computed properties
const hasCodexUsage = computed(() => { const hasCodexUsage = computed(() => {
const extra = props.account.extra const extra = props.account.extra
return extra && ( return (
extra &&
// Check for new canonical fields first // Check for new canonical fields first
extra.codex_5h_used_percent !== undefined || (extra.codex_5h_used_percent !== undefined ||
extra.codex_7d_used_percent !== undefined || extra.codex_7d_used_percent !== undefined ||
// Fallback to legacy fields // Fallback to legacy fields
extra.codex_primary_used_percent !== undefined || extra.codex_primary_used_percent !== undefined ||
extra.codex_secondary_used_percent !== undefined extra.codex_secondary_used_percent !== undefined)
) )
}) })
...@@ -145,10 +147,16 @@ const codex5hUsedPercent = computed(() => { ...@@ -145,10 +147,16 @@ const codex5hUsedPercent = computed(() => {
} }
// Fallback: detect from legacy fields using window_minutes // Fallback: detect from legacy fields using window_minutes
if (extra.codex_primary_window_minutes !== undefined && extra.codex_primary_window_minutes <= 360) { if (
extra.codex_primary_window_minutes !== undefined &&
extra.codex_primary_window_minutes <= 360
) {
return extra.codex_primary_used_percent ?? null return extra.codex_primary_used_percent ?? null
} }
if (extra.codex_secondary_window_minutes !== undefined && extra.codex_secondary_window_minutes <= 360) { if (
extra.codex_secondary_window_minutes !== undefined &&
extra.codex_secondary_window_minutes <= 360
) {
return extra.codex_secondary_used_percent ?? null return extra.codex_secondary_used_percent ?? null
} }
...@@ -167,13 +175,19 @@ const codex5hResetAt = computed(() => { ...@@ -167,13 +175,19 @@ const codex5hResetAt = computed(() => {
} }
// Fallback: detect from legacy fields using window_minutes // Fallback: detect from legacy fields using window_minutes
if (extra.codex_primary_window_minutes !== undefined && extra.codex_primary_window_minutes <= 360) { if (
extra.codex_primary_window_minutes !== undefined &&
extra.codex_primary_window_minutes <= 360
) {
if (extra.codex_primary_reset_after_seconds !== undefined) { if (extra.codex_primary_reset_after_seconds !== undefined) {
const resetTime = new Date(Date.now() + extra.codex_primary_reset_after_seconds * 1000) const resetTime = new Date(Date.now() + extra.codex_primary_reset_after_seconds * 1000)
return resetTime.toISOString() return resetTime.toISOString()
} }
} }
if (extra.codex_secondary_window_minutes !== undefined && extra.codex_secondary_window_minutes <= 360) { if (
extra.codex_secondary_window_minutes !== undefined &&
extra.codex_secondary_window_minutes <= 360
) {
if (extra.codex_secondary_reset_after_seconds !== undefined) { if (extra.codex_secondary_reset_after_seconds !== undefined) {
const resetTime = new Date(Date.now() + extra.codex_secondary_reset_after_seconds * 1000) const resetTime = new Date(Date.now() + extra.codex_secondary_reset_after_seconds * 1000)
return resetTime.toISOString() return resetTime.toISOString()
...@@ -200,10 +214,16 @@ const codex7dUsedPercent = computed(() => { ...@@ -200,10 +214,16 @@ const codex7dUsedPercent = computed(() => {
} }
// Fallback: detect from legacy fields using window_minutes // Fallback: detect from legacy fields using window_minutes
if (extra.codex_primary_window_minutes !== undefined && extra.codex_primary_window_minutes >= 10000) { if (
extra.codex_primary_window_minutes !== undefined &&
extra.codex_primary_window_minutes >= 10000
) {
return extra.codex_primary_used_percent ?? null return extra.codex_primary_used_percent ?? null
} }
if (extra.codex_secondary_window_minutes !== undefined && extra.codex_secondary_window_minutes >= 10000) { if (
extra.codex_secondary_window_minutes !== undefined &&
extra.codex_secondary_window_minutes >= 10000
) {
return extra.codex_secondary_used_percent ?? null return extra.codex_secondary_used_percent ?? null
} }
...@@ -222,13 +242,19 @@ const codex7dResetAt = computed(() => { ...@@ -222,13 +242,19 @@ const codex7dResetAt = computed(() => {
} }
// Fallback: detect from legacy fields using window_minutes // Fallback: detect from legacy fields using window_minutes
if (extra.codex_primary_window_minutes !== undefined && extra.codex_primary_window_minutes >= 10000) { if (
extra.codex_primary_window_minutes !== undefined &&
extra.codex_primary_window_minutes >= 10000
) {
if (extra.codex_primary_reset_after_seconds !== undefined) { if (extra.codex_primary_reset_after_seconds !== undefined) {
const resetTime = new Date(Date.now() + extra.codex_primary_reset_after_seconds * 1000) const resetTime = new Date(Date.now() + extra.codex_primary_reset_after_seconds * 1000)
return resetTime.toISOString() return resetTime.toISOString()
} }
} }
if (extra.codex_secondary_window_minutes !== undefined && extra.codex_secondary_window_minutes >= 10000) { if (
extra.codex_secondary_window_minutes !== undefined &&
extra.codex_secondary_window_minutes >= 10000
) {
if (extra.codex_secondary_reset_after_seconds !== undefined) { if (extra.codex_secondary_reset_after_seconds !== undefined) {
const resetTime = new Date(Date.now() + extra.codex_secondary_reset_after_seconds * 1000) const resetTime = new Date(Date.now() + extra.codex_secondary_reset_after_seconds * 1000)
return resetTime.toISOString() return resetTime.toISOString()
......
...@@ -2,9 +2,9 @@ ...@@ -2,9 +2,9 @@
<Modal :show="show" :title="t('admin.accounts.bulkEdit.title')" size="lg" @close="handleClose"> <Modal :show="show" :title="t('admin.accounts.bulkEdit.title')" size="lg" @close="handleClose">
<form class="space-y-5" @submit.prevent="handleSubmit"> <form class="space-y-5" @submit.prevent="handleSubmit">
<!-- Info --> <!-- Info -->
<div class="rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4"> <div class="rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20">
<p class="text-sm text-blue-700 dark:text-blue-400"> <p class="text-sm text-blue-700 dark:text-blue-400">
<svg class="w-5 h-5 inline mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="mr-1.5 inline h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
...@@ -17,8 +17,8 @@ ...@@ -17,8 +17,8 @@
</div> </div>
<!-- Base URL (API Key only) --> <!-- Base URL (API Key only) -->
<div class="border-t border-gray-200 dark:border-dark-600 pt-4"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="flex items-center justify-between mb-3"> <div class="mb-3 flex items-center justify-between">
<label class="input-label mb-0">{{ t('admin.accounts.baseUrl') }}</label> <label class="input-label mb-0">{{ t('admin.accounts.baseUrl') }}</label>
<input <input
v-model="enableBaseUrl" v-model="enableBaseUrl"
...@@ -31,7 +31,7 @@ ...@@ -31,7 +31,7 @@
type="text" type="text"
:disabled="!enableBaseUrl" :disabled="!enableBaseUrl"
class="input" class="input"
:class="!enableBaseUrl && 'opacity-50 cursor-not-allowed'" :class="!enableBaseUrl && 'cursor-not-allowed opacity-50'"
:placeholder="t('admin.accounts.bulkEdit.baseUrlPlaceholder')" :placeholder="t('admin.accounts.bulkEdit.baseUrlPlaceholder')"
/> />
<p class="input-hint"> <p class="input-hint">
...@@ -40,8 +40,8 @@ ...@@ -40,8 +40,8 @@
</div> </div>
<!-- Model restriction --> <!-- Model restriction -->
<div class="border-t border-gray-200 dark:border-dark-600 pt-4"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="flex items-center justify-between mb-3"> <div class="mb-3 flex items-center justify-between">
<label class="input-label mb-0">{{ t('admin.accounts.modelRestriction') }}</label> <label class="input-label mb-0">{{ t('admin.accounts.modelRestriction') }}</label>
<input <input
v-model="enableModelRestriction" v-model="enableModelRestriction"
...@@ -50,21 +50,21 @@ ...@@ -50,21 +50,21 @@
/> />
</div> </div>
<div :class="!enableModelRestriction && 'opacity-50 pointer-events-none'"> <div :class="!enableModelRestriction && 'pointer-events-none opacity-50'">
<!-- Mode Toggle --> <!-- Mode Toggle -->
<div class="flex gap-2 mb-4"> <div class="mb-4 flex gap-2">
<button <button
type="button" type="button"
:class="[ :class="[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all', 'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
modelRestrictionMode === 'whitelist' modelRestrictionMode === 'whitelist'
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400' ? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500', : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]" ]"
@click="modelRestrictionMode = 'whitelist'" @click="modelRestrictionMode = 'whitelist'"
> >
<svg <svg
class="w-4 h-4 inline mr-1.5" class="mr-1.5 inline h-4 w-4"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
...@@ -84,12 +84,12 @@ ...@@ -84,12 +84,12 @@
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all', 'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
modelRestrictionMode === 'mapping' modelRestrictionMode === 'mapping'
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500', : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]" ]"
@click="modelRestrictionMode = 'mapping'" @click="modelRestrictionMode = 'mapping'"
> >
<svg <svg
class="w-4 h-4 inline mr-1.5" class="mr-1.5 inline h-4 w-4"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
...@@ -107,10 +107,10 @@ ...@@ -107,10 +107,10 @@
<!-- Whitelist Mode --> <!-- Whitelist Mode -->
<div v-if="modelRestrictionMode === 'whitelist'"> <div v-if="modelRestrictionMode === 'whitelist'">
<div class="mb-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-3"> <div class="mb-3 rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
<p class="text-xs text-blue-700 dark:text-blue-400"> <p class="text-xs text-blue-700 dark:text-blue-400">
<svg <svg
class="w-4 h-4 inline mr-1" class="mr-1 inline h-4 w-4"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
...@@ -127,7 +127,7 @@ ...@@ -127,7 +127,7 @@
</div> </div>
<!-- Model Checkbox List --> <!-- Model Checkbox List -->
<div class="grid grid-cols-2 gap-2 mb-3"> <div class="mb-3 grid grid-cols-2 gap-2">
<label <label
v-for="model in allModels" v-for="model in allModels"
:key="model.value" :key="model.value"
...@@ -158,10 +158,10 @@ ...@@ -158,10 +158,10 @@
<!-- Mapping Mode --> <!-- Mapping Mode -->
<div v-else> <div v-else>
<div class="mb-3 rounded-lg bg-purple-50 dark:bg-purple-900/20 p-3"> <div class="mb-3 rounded-lg bg-purple-50 p-3 dark:bg-purple-900/20">
<p class="text-xs text-purple-700 dark:text-purple-400"> <p class="text-xs text-purple-700 dark:text-purple-400">
<svg <svg
class="w-4 h-4 inline mr-1" class="mr-1 inline h-4 w-4"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
...@@ -178,7 +178,7 @@ ...@@ -178,7 +178,7 @@
</div> </div>
<!-- Model Mapping List --> <!-- Model Mapping List -->
<div v-if="modelMappings.length > 0" class="space-y-2 mb-3"> <div v-if="modelMappings.length > 0" class="mb-3 space-y-2">
<div <div
v-for="(mapping, index) in modelMappings" v-for="(mapping, index) in modelMappings"
:key="index" :key="index"
...@@ -191,7 +191,7 @@ ...@@ -191,7 +191,7 @@
:placeholder="t('admin.accounts.requestModel')" :placeholder="t('admin.accounts.requestModel')"
/> />
<svg <svg
class="w-4 h-4 text-gray-400 flex-shrink-0" class="h-4 w-4 flex-shrink-0 text-gray-400"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
...@@ -211,10 +211,10 @@ ...@@ -211,10 +211,10 @@
/> />
<button <button
type="button" type="button"
class="p-2 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors" class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
@click="removeModelMapping(index)" @click="removeModelMapping(index)"
> >
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
...@@ -228,11 +228,11 @@ ...@@ -228,11 +228,11 @@
<button <button
type="button" type="button"
class="w-full rounded-lg border-2 border-dashed border-gray-300 dark:border-dark-500 px-4 py-2 text-gray-600 dark:text-gray-400 transition-colors hover:border-gray-400 hover:text-gray-700 dark:hover:border-dark-400 dark:hover:text-gray-300 mb-3" class="mb-3 w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
@click="addModelMapping" @click="addModelMapping"
> >
<svg <svg
class="w-4 h-4 inline mr-1" class="mr-1 inline h-4 w-4"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
...@@ -264,11 +264,11 @@ ...@@ -264,11 +264,11 @@
</div> </div>
<!-- Custom error codes --> <!-- Custom error codes -->
<div class="border-t border-gray-200 dark:border-dark-600 pt-4"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="flex items-center justify-between mb-3"> <div class="mb-3 flex items-center justify-between">
<div> <div>
<label class="input-label mb-0">{{ t('admin.accounts.customErrorCodes') }}</label> <label class="input-label mb-0">{{ t('admin.accounts.customErrorCodes') }}</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.customErrorCodesHint') }} {{ t('admin.accounts.customErrorCodesHint') }}
</p> </p>
</div> </div>
...@@ -280,10 +280,10 @@ ...@@ -280,10 +280,10 @@
</div> </div>
<div v-if="enableCustomErrorCodes" class="space-y-3"> <div v-if="enableCustomErrorCodes" class="space-y-3">
<div class="rounded-lg bg-amber-50 dark:bg-amber-900/20 p-3"> <div class="rounded-lg bg-amber-50 p-3 dark:bg-amber-900/20">
<p class="text-xs text-amber-700 dark:text-amber-400"> <p class="text-xs text-amber-700 dark:text-amber-400">
<svg <svg
class="w-4 h-4 inline mr-1" class="mr-1 inline h-4 w-4"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
...@@ -308,8 +308,8 @@ ...@@ -308,8 +308,8 @@
:class="[ :class="[
'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors', 'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
selectedErrorCodes.includes(code.value) selectedErrorCodes.includes(code.value)
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 ring-1 ring-red-500' ? 'bg-red-100 text-red-700 ring-1 ring-red-500 dark:bg-red-900/30 dark:text-red-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500', : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]" ]"
@click="toggleErrorCode(code.value)" @click="toggleErrorCode(code.value)"
> >
...@@ -329,7 +329,7 @@ ...@@ -329,7 +329,7 @@
@keyup.enter="addCustomErrorCode" @keyup.enter="addCustomErrorCode"
/> />
<button type="button" class="btn btn-secondary px-3" @click="addCustomErrorCode"> <button type="button" class="btn btn-secondary px-3" @click="addCustomErrorCode">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
...@@ -345,7 +345,7 @@ ...@@ -345,7 +345,7 @@
<span <span
v-for="code in selectedErrorCodes.sort((a, b) => a - b)" v-for="code in selectedErrorCodes.sort((a, b) => a - b)"
:key="code" :key="code"
class="inline-flex items-center gap-1 rounded-full bg-red-100 dark:bg-red-900/30 px-2.5 py-0.5 text-sm font-medium text-red-700 dark:text-red-400" class="inline-flex items-center gap-1 rounded-full bg-red-100 px-2.5 py-0.5 text-sm font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
> >
{{ code }} {{ code }}
<button <button
...@@ -353,7 +353,7 @@ ...@@ -353,7 +353,7 @@
class="hover:text-red-900 dark:hover:text-red-300" class="hover:text-red-900 dark:hover:text-red-300"
@click="removeErrorCode(code)" @click="removeErrorCode(code)"
> >
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
...@@ -371,13 +371,13 @@ ...@@ -371,13 +371,13 @@
</div> </div>
<!-- Intercept warmup requests (Anthropic only) --> <!-- Intercept warmup requests (Anthropic only) -->
<div class="border-t border-gray-200 dark:border-dark-600 pt-4"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex-1 pr-4"> <div class="flex-1 pr-4">
<label class="input-label mb-0">{{ <label class="input-label mb-0">{{
t('admin.accounts.interceptWarmupRequests') t('admin.accounts.interceptWarmupRequests')
}}</label> }}</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.interceptWarmupRequestsDesc') }} {{ t('admin.accounts.interceptWarmupRequestsDesc') }}
</p> </p>
</div> </div>
...@@ -392,14 +392,14 @@ ...@@ -392,14 +392,14 @@
type="button" type="button"
:class="[ :class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2', 'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
interceptWarmupRequests ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600', interceptWarmupRequests ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]" ]"
@click="interceptWarmupRequests = !interceptWarmupRequests" @click="interceptWarmupRequests = !interceptWarmupRequests"
> >
<span <span
:class="[ :class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out', 'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
interceptWarmupRequests ? 'translate-x-5' : 'translate-x-0', interceptWarmupRequests ? 'translate-x-5' : 'translate-x-0'
]" ]"
/> />
</button> </button>
...@@ -407,8 +407,8 @@ ...@@ -407,8 +407,8 @@
</div> </div>
<!-- Proxy --> <!-- Proxy -->
<div class="border-t border-gray-200 dark:border-dark-600 pt-4"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="flex items-center justify-between mb-3"> <div class="mb-3 flex items-center justify-between">
<label class="input-label mb-0">{{ t('admin.accounts.proxy') }}</label> <label class="input-label mb-0">{{ t('admin.accounts.proxy') }}</label>
<input <input
v-model="enableProxy" v-model="enableProxy"
...@@ -416,15 +416,15 @@ ...@@ -416,15 +416,15 @@
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/> />
</div> </div>
<div :class="!enableProxy && 'opacity-50 pointer-events-none'"> <div :class="!enableProxy && 'pointer-events-none opacity-50'">
<ProxySelector v-model="proxyId" :proxies="proxies" /> <ProxySelector v-model="proxyId" :proxies="proxies" />
</div> </div>
</div> </div>
<!-- Concurrency & Priority --> <!-- Concurrency & Priority -->
<div class="grid grid-cols-2 gap-4 border-t border-gray-200 dark:border-dark-600 pt-4"> <div class="grid grid-cols-2 gap-4 border-t border-gray-200 pt-4 dark:border-dark-600">
<div> <div>
<div class="flex items-center justify-between mb-3"> <div class="mb-3 flex items-center justify-between">
<label class="input-label mb-0">{{ t('admin.accounts.concurrency') }}</label> <label class="input-label mb-0">{{ t('admin.accounts.concurrency') }}</label>
<input <input
v-model="enableConcurrency" v-model="enableConcurrency"
...@@ -438,11 +438,11 @@ ...@@ -438,11 +438,11 @@
min="1" min="1"
:disabled="!enableConcurrency" :disabled="!enableConcurrency"
class="input" class="input"
:class="!enableConcurrency && 'opacity-50 cursor-not-allowed'" :class="!enableConcurrency && 'cursor-not-allowed opacity-50'"
/> />
</div> </div>
<div> <div>
<div class="flex items-center justify-between mb-3"> <div class="mb-3 flex items-center justify-between">
<label class="input-label mb-0">{{ t('admin.accounts.priority') }}</label> <label class="input-label mb-0">{{ t('admin.accounts.priority') }}</label>
<input <input
v-model="enablePriority" v-model="enablePriority"
...@@ -456,14 +456,14 @@ ...@@ -456,14 +456,14 @@
min="1" min="1"
:disabled="!enablePriority" :disabled="!enablePriority"
class="input" class="input"
:class="!enablePriority && 'opacity-50 cursor-not-allowed'" :class="!enablePriority && 'cursor-not-allowed opacity-50'"
/> />
</div> </div>
</div> </div>
<!-- Status --> <!-- Status -->
<div class="border-t border-gray-200 dark:border-dark-600 pt-4"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="flex items-center justify-between mb-3"> <div class="mb-3 flex items-center justify-between">
<label class="input-label mb-0">{{ t('common.status') }}</label> <label class="input-label mb-0">{{ t('common.status') }}</label>
<input <input
v-model="enableStatus" v-model="enableStatus"
...@@ -471,14 +471,14 @@ ...@@ -471,14 +471,14 @@
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/> />
</div> </div>
<div :class="!enableStatus && 'opacity-50 pointer-events-none'"> <div :class="!enableStatus && 'pointer-events-none opacity-50'">
<Select v-model="status" :options="statusOptions" /> <Select v-model="status" :options="statusOptions" />
</div> </div>
</div> </div>
<!-- Groups --> <!-- Groups -->
<div class="border-t border-gray-200 dark:border-dark-600 pt-4"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="flex items-center justify-between mb-3"> <div class="mb-3 flex items-center justify-between">
<label class="input-label mb-0">{{ t('nav.groups') }}</label> <label class="input-label mb-0">{{ t('nav.groups') }}</label>
<input <input
v-model="enableGroups" v-model="enableGroups"
...@@ -486,7 +486,7 @@ ...@@ -486,7 +486,7 @@
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/> />
</div> </div>
<div :class="!enableGroups && 'opacity-50 pointer-events-none'"> <div :class="!enableGroups && 'pointer-events-none opacity-50'">
<GroupSelector v-model="groupIds" :groups="groups" /> <GroupSelector v-model="groupIds" :groups="groups" />
</div> </div>
</div> </div>
...@@ -499,7 +499,7 @@ ...@@ -499,7 +499,7 @@
<button type="submit" :disabled="submitting" class="btn btn-primary"> <button type="submit" :disabled="submitting" class="btn btn-primary">
<svg <svg
v-if="submitting" v-if="submitting"
class="animate-spin -ml-1 mr-2 h-4 w-4" class="-ml-1 mr-2 h-4 w-4 animate-spin"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
...@@ -601,7 +601,7 @@ const allModels = [ ...@@ -601,7 +601,7 @@ const allModels = [
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' }, { value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
{ value: 'gpt-5.1-2025-11-13', label: 'GPT-5.1' }, { value: 'gpt-5.1-2025-11-13', label: 'GPT-5.1' },
{ value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' }, { value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' },
{ value: 'gpt-5-2025-08-07', label: 'GPT-5' }, { value: 'gpt-5-2025-08-07', label: 'GPT-5' }
] ]
// Preset mappings (combined Anthropic + OpenAI) // Preset mappings (combined Anthropic + OpenAI)
...@@ -610,48 +610,46 @@ const presetMappings = [ ...@@ -610,48 +610,46 @@ const presetMappings = [
label: 'Sonnet 4', label: 'Sonnet 4',
from: 'claude-sonnet-4-20250514', from: 'claude-sonnet-4-20250514',
to: 'claude-sonnet-4-20250514', to: 'claude-sonnet-4-20250514',
color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400'
}, },
{ {
label: 'Sonnet 4.5', label: 'Sonnet 4.5',
from: 'claude-sonnet-4-5-20250929', from: 'claude-sonnet-4-5-20250929',
to: 'claude-sonnet-4-5-20250929', to: 'claude-sonnet-4-5-20250929',
color: color:
'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400', 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400'
}, },
{ {
label: 'Opus 4.5', label: 'Opus 4.5',
from: 'claude-opus-4-5-20251101', from: 'claude-opus-4-5-20251101',
to: 'claude-opus-4-5-20251101', to: 'claude-opus-4-5-20251101',
color: color:
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400', 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
}, },
{ {
label: 'Opus->Sonnet', label: 'Opus->Sonnet',
from: 'claude-opus-4-5-20251101', from: 'claude-opus-4-5-20251101',
to: 'claude-sonnet-4-5-20250929', to: 'claude-sonnet-4-5-20250929',
color: color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400'
'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400',
}, },
{ {
label: 'GPT-5.2', label: 'GPT-5.2',
from: 'gpt-5.2-2025-12-11', from: 'gpt-5.2-2025-12-11',
to: 'gpt-5.2-2025-12-11', to: 'gpt-5.2-2025-12-11',
color: color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400'
'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400',
}, },
{ {
label: 'GPT-5.2 Codex', label: 'GPT-5.2 Codex',
from: 'gpt-5.2-codex', from: 'gpt-5.2-codex',
to: 'gpt-5.2-codex', to: 'gpt-5.2-codex',
color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400'
}, },
{ {
label: 'Max->Codex', label: 'Max->Codex',
from: 'gpt-5.1-codex-max', from: 'gpt-5.1-codex-max',
to: 'gpt-5.1-codex', to: 'gpt-5.1-codex',
color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400'
}, }
] ]
// Common HTTP error codes // Common HTTP error codes
...@@ -662,12 +660,12 @@ const commonErrorCodes = [ ...@@ -662,12 +660,12 @@ const commonErrorCodes = [
{ value: 500, label: 'Server Error' }, { value: 500, label: 'Server Error' },
{ value: 502, label: 'Bad Gateway' }, { value: 502, label: 'Bad Gateway' },
{ value: 503, label: 'Unavailable' }, { value: 503, label: 'Unavailable' },
{ value: 529, label: 'Overloaded' }, { value: 529, label: 'Overloaded' }
] ]
const statusOptions = computed(() => [ const statusOptions = computed(() => [
{ value: 'active', label: t('common.active') }, { value: 'active', label: t('common.active') },
{ value: 'inactive', label: t('common.inactive') }, { value: 'inactive', label: t('common.inactive') }
]) ])
// Model mapping helpers // Model mapping helpers
......
...@@ -10,10 +10,15 @@ ...@@ -10,10 +10,15 @@
<div class="text-sm text-gray-600 dark:text-dark-300"> <div class="text-sm text-gray-600 dark:text-dark-300">
{{ t('admin.accounts.syncFromCrsDesc') }} {{ t('admin.accounts.syncFromCrsDesc') }}
</div> </div>
<div class="text-xs text-gray-500 dark:text-dark-400 bg-gray-50 dark:bg-dark-700/60 rounded-lg p-3"> <div
已有账号仅同步 CRS 返回的字段,缺失字段保持原值;凭据按键合并,不会清空未下发的键;未勾选"同步代理"时保留原有代理。 class="rounded-lg bg-gray-50 p-3 text-xs text-gray-500 dark:bg-dark-700/60 dark:text-dark-400"
>
已有账号仅同步 CRS
返回的字段,缺失字段保持原值;凭据按键合并,不会清空未下发的键;未勾选"同步代理"时保留原有代理。
</div> </div>
<div class="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3"> <div
class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-600 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-400"
>
{{ t('admin.accounts.crsVersionRequirement') }} {{ t('admin.accounts.crsVersionRequirement') }}
</div> </div>
...@@ -31,12 +36,7 @@ ...@@ -31,12 +36,7 @@
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div> <div>
<label class="input-label">{{ t('admin.accounts.crsUsername') }}</label> <label class="input-label">{{ t('admin.accounts.crsUsername') }}</label>
<input <input v-model="form.username" type="text" class="input" autocomplete="username" />
v-model="form.username"
type="text"
class="input"
autocomplete="username"
/>
</div> </div>
<div> <div>
<label class="input-label">{{ t('admin.accounts.crsPassword') }}</label> <label class="input-label">{{ t('admin.accounts.crsPassword') }}</label>
...@@ -50,12 +50,19 @@ ...@@ -50,12 +50,19 @@
</div> </div>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-dark-300"> <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-dark-300">
<input v-model="form.sync_proxies" type="checkbox" class="rounded border-gray-300 dark:border-dark-600" /> <input
v-model="form.sync_proxies"
type="checkbox"
class="rounded border-gray-300 dark:border-dark-600"
/>
{{ t('admin.accounts.syncProxies') }} {{ t('admin.accounts.syncProxies') }}
</label> </label>
</div> </div>
<div v-if="result" class="rounded-xl border border-gray-200 dark:border-dark-700 p-4 space-y-2"> <div
v-if="result"
class="space-y-2 rounded-xl border border-gray-200 p-4 dark:border-dark-700"
>
<div class="text-sm font-medium text-gray-900 dark:text-white"> <div class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.accounts.syncResult') }} {{ t('admin.accounts.syncResult') }}
</div> </div>
...@@ -67,9 +74,12 @@ ...@@ -67,9 +74,12 @@
<div class="text-sm font-medium text-red-600 dark:text-red-400"> <div class="text-sm font-medium text-red-600 dark:text-red-400">
{{ t('admin.accounts.syncErrors') }} {{ t('admin.accounts.syncErrors') }}
</div> </div>
<div class="mt-2 max-h-48 overflow-auto rounded-lg bg-gray-50 dark:bg-dark-800 p-3 text-xs font-mono"> <div
class="mt-2 max-h-48 overflow-auto rounded-lg bg-gray-50 p-3 font-mono text-xs dark:bg-dark-800"
>
<div v-for="(item, idx) in errorItems" :key="idx" class="whitespace-pre-wrap"> <div v-for="(item, idx) in errorItems" :key="idx" class="whitespace-pre-wrap">
{{ item.kind }} {{ item.crs_account_id }}{{ item.action }}{{ item.error ? `: ${item.error}` : '' }} {{ item.kind }} {{ item.crs_account_id }}{{ item.action
}}{{ item.error ? `: ${item.error}` : '' }}
</div> </div>
</div> </div>
</div> </div>
......
<template> <template>
<div> <div>
<!-- Window stats row (above progress bar, left-right aligned with progress bar) --> <!-- Window stats row (above progress bar, left-right aligned with progress bar) -->
<div v-if="windowStats" class="flex items-center justify-between mb-0.5" :title="`5h 窗口用量统计`"> <div
<div class="flex items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400 cursor-help"> v-if="windowStats"
<span class="px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800"> class="mb-0.5 flex items-center justify-between"
:title="`5h 窗口用量统计`"
>
<div
class="flex cursor-help items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400"
>
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
{{ formatRequests }} req {{ formatRequests }} req
</span> </span>
<span class="px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800"> <span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
{{ formatTokens }} {{ formatTokens }}
</span> </span>
<span class="px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800"> <span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"> ${{ formatCost }} </span>
${{ formatCost }}
</span>
</div> </div>
</div> </div>
...@@ -19,16 +23,13 @@ ...@@ -19,16 +23,13 @@
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<!-- Label badge (fixed width for alignment) --> <!-- Label badge (fixed width for alignment) -->
<span <span
:class="[ :class="['w-[32px] shrink-0 rounded px-1 text-center text-[10px] font-medium', labelClass]"
'text-[10px] font-medium px-1 rounded w-[32px] text-center shrink-0',
labelClass
]"
> >
{{ label }} {{ label }}
</span> </span>
<!-- Progress bar container --> <!-- Progress bar container -->
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden shrink-0"> <div class="h-1.5 w-8 shrink-0 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div <div
:class="['h-full transition-all duration-300', barClass]" :class="['h-full transition-all duration-300', barClass]"
:style="{ width: barWidth }" :style="{ width: barWidth }"
...@@ -36,12 +37,12 @@ ...@@ -36,12 +37,12 @@
</div> </div>
<!-- Percentage --> <!-- Percentage -->
<span :class="['text-[10px] font-medium w-[32px] text-right shrink-0', textClass]"> <span :class="['w-[32px] shrink-0 text-right text-[10px] font-medium', textClass]">
{{ displayPercent }} {{ displayPercent }}
</span> </span>
<!-- Reset time --> <!-- Reset time -->
<span v-if="resetsAt" class="text-[10px] text-gray-400 shrink-0"> <span v-if="resetsAt" class="shrink-0 text-[10px] text-gray-400">
{{ formatResetTime }} {{ formatResetTime }}
</span> </span>
</div> </div>
...@@ -54,7 +55,7 @@ import type { WindowStats } from '@/types' ...@@ -54,7 +55,7 @@ import type { WindowStats } from '@/types'
const props = defineProps<{ const props = defineProps<{
label: string label: string
utilization: number // Percentage (0-100+) utilization: number // Percentage (0-100+)
resetsAt?: string | null resetsAt?: string | null
color: 'indigo' | 'emerald' | 'purple' color: 'indigo' | 'emerald' | 'purple'
windowStats?: WindowStats | null windowStats?: WindowStats | null
......
<template> <template>
<div class="card p-4"> <div class="card p-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">{{ t('admin.dashboard.modelDistribution') }}</h3> <h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
<div v-if="loading" class="flex items-center justify-center h-48"> {{ t('admin.dashboard.modelDistribution') }}
</h3>
<div v-if="loading" class="flex h-48 items-center justify-center">
<LoadingSpinner /> <LoadingSpinner />
</div> </div>
<div v-else-if="modelStats.length > 0 && chartData" class="flex items-center gap-6"> <div v-else-if="modelStats.length > 0 && chartData" class="flex items-center gap-6">
<div class="w-48 h-48"> <div class="h-48 w-48">
<Doughnut :data="chartData" :options="doughnutOptions" /> <Doughnut :data="chartData" :options="doughnutOptions" />
</div> </div>
<div class="flex-1 max-h-48 overflow-y-auto"> <div class="max-h-48 flex-1 overflow-y-auto">
<table class="w-full text-xs"> <table class="w-full text-xs">
<thead> <thead>
<tr class="text-gray-500 dark:text-gray-400"> <tr class="text-gray-500 dark:text-gray-400">
<th class="text-left pb-2">{{ t('admin.dashboard.model') }}</th> <th class="pb-2 text-left">{{ t('admin.dashboard.model') }}</th>
<th class="text-right pb-2">{{ t('admin.dashboard.requests') }}</th> <th class="pb-2 text-right">{{ t('admin.dashboard.requests') }}</th>
<th class="text-right pb-2">{{ t('admin.dashboard.tokens') }}</th> <th class="pb-2 text-right">{{ t('admin.dashboard.tokens') }}</th>
<th class="text-right pb-2">{{ t('admin.dashboard.actual') }}</th> <th class="pb-2 text-right">{{ t('admin.dashboard.actual') }}</th>
<th class="text-right pb-2">{{ t('admin.dashboard.standard') }}</th> <th class="pb-2 text-right">{{ t('admin.dashboard.standard') }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="model in modelStats" :key="model.model" class="border-t border-gray-100 dark:border-gray-700"> <tr
<td class="py-1.5 text-gray-900 dark:text-white font-medium truncate max-w-[100px]" :title="model.model">{{ model.model }}</td> v-for="model in modelStats"
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">{{ formatNumber(model.requests) }}</td> :key="model.model"
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">{{ formatTokens(model.total_tokens) }}</td> class="border-t border-gray-100 dark:border-gray-700"
<td class="py-1.5 text-right text-green-600 dark:text-green-400">${{ formatCost(model.actual_cost) }}</td> >
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">${{ formatCost(model.cost) }}</td> <td
class="max-w-[100px] truncate py-1.5 font-medium text-gray-900 dark:text-white"
:title="model.model"
>
{{ model.model }}
</td>
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
{{ formatNumber(model.requests) }}
</td>
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
{{ formatTokens(model.total_tokens) }}
</td>
<td class="py-1.5 text-right text-green-600 dark:text-green-400">
${{ formatCost(model.actual_cost) }}
</td>
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">
${{ formatCost(model.cost) }}
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
<div v-else class="flex items-center justify-center h-48 text-gray-500 dark:text-gray-400 text-sm"> <div
v-else
class="flex h-48 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
{{ t('admin.dashboard.noDataAvailable') }} {{ t('admin.dashboard.noDataAvailable') }}
</div> </div>
</div> </div>
...@@ -40,12 +62,7 @@ ...@@ -40,12 +62,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js'
Chart as ChartJS,
ArcElement,
Tooltip,
Legend
} from 'chart.js'
import { Doughnut } from 'vue-chartjs' import { Doughnut } from 'vue-chartjs'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue' import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import type { ModelStat } from '@/types' import type { ModelStat } from '@/types'
...@@ -60,20 +77,30 @@ const props = defineProps<{ ...@@ -60,20 +77,30 @@ const props = defineProps<{
}>() }>()
const chartColors = [ const chartColors = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#3b82f6',
'#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16' '#10b981',
'#f59e0b',
'#ef4444',
'#8b5cf6',
'#ec4899',
'#14b8a6',
'#f97316',
'#6366f1',
'#84cc16'
] ]
const chartData = computed(() => { const chartData = computed(() => {
if (!props.modelStats?.length) return null if (!props.modelStats?.length) return null
return { return {
labels: props.modelStats.map(m => m.model), labels: props.modelStats.map((m) => m.model),
datasets: [{ datasets: [
data: props.modelStats.map(m => m.total_tokens), {
backgroundColor: chartColors.slice(0, props.modelStats.length), data: props.modelStats.map((m) => m.total_tokens),
borderWidth: 0, backgroundColor: chartColors.slice(0, props.modelStats.length),
}], borderWidth: 0
}
]
} }
}) })
...@@ -82,7 +109,7 @@ const doughnutOptions = computed(() => ({ ...@@ -82,7 +109,7 @@ const doughnutOptions = computed(() => ({
maintainAspectRatio: false, maintainAspectRatio: false,
plugins: { plugins: {
legend: { legend: {
display: false, display: false
}, },
tooltip: { tooltip: {
callbacks: { callbacks: {
...@@ -91,10 +118,10 @@ const doughnutOptions = computed(() => ({ ...@@ -91,10 +118,10 @@ const doughnutOptions = computed(() => ({
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0) const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
const percentage = ((value / total) * 100).toFixed(1) const percentage = ((value / total) * 100).toFixed(1)
return `${context.label}: ${formatTokens(value)} (${percentage}%)` return `${context.label}: ${formatTokens(value)} (${percentage}%)`
}, }
}, }
}, }
}, }
})) }))
const formatTokens = (value: number): string => { const formatTokens = (value: number): string => {
......
<template> <template>
<div class="card p-4"> <div class="card p-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">{{ t('admin.dashboard.tokenUsageTrend') }}</h3> <h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
<div v-if="loading" class="flex items-center justify-center h-48"> {{ t('admin.dashboard.tokenUsageTrend') }}
</h3>
<div v-if="loading" class="flex h-48 items-center justify-center">
<LoadingSpinner /> <LoadingSpinner />
</div> </div>
<div v-else-if="trendData.length > 0 && chartData" class="h-48"> <div v-else-if="trendData.length > 0 && chartData" class="h-48">
<Line :data="chartData" :options="lineOptions" /> <Line :data="chartData" :options="lineOptions" />
</div> </div>
<div v-else class="flex items-center justify-center h-48 text-gray-500 dark:text-gray-400 text-sm"> <div
v-else
class="flex h-48 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
{{ t('admin.dashboard.noDataAvailable') }} {{ t('admin.dashboard.noDataAvailable') }}
</div> </div>
</div> </div>
...@@ -58,40 +63,40 @@ const chartColors = computed(() => ({ ...@@ -58,40 +63,40 @@ const chartColors = computed(() => ({
grid: isDarkMode.value ? '#374151' : '#e5e7eb', grid: isDarkMode.value ? '#374151' : '#e5e7eb',
input: '#3b82f6', input: '#3b82f6',
output: '#10b981', output: '#10b981',
cache: '#f59e0b', cache: '#f59e0b'
})) }))
const chartData = computed(() => { const chartData = computed(() => {
if (!props.trendData?.length) return null if (!props.trendData?.length) return null
return { return {
labels: props.trendData.map(d => d.date), labels: props.trendData.map((d) => d.date),
datasets: [ datasets: [
{ {
label: 'Input', label: 'Input',
data: props.trendData.map(d => d.input_tokens), data: props.trendData.map((d) => d.input_tokens),
borderColor: chartColors.value.input, borderColor: chartColors.value.input,
backgroundColor: `${chartColors.value.input}20`, backgroundColor: `${chartColors.value.input}20`,
fill: true, fill: true,
tension: 0.3, tension: 0.3
}, },
{ {
label: 'Output', label: 'Output',
data: props.trendData.map(d => d.output_tokens), data: props.trendData.map((d) => d.output_tokens),
borderColor: chartColors.value.output, borderColor: chartColors.value.output,
backgroundColor: `${chartColors.value.output}20`, backgroundColor: `${chartColors.value.output}20`,
fill: true, fill: true,
tension: 0.3, tension: 0.3
}, },
{ {
label: 'Cache', label: 'Cache',
data: props.trendData.map(d => d.cache_tokens), data: props.trendData.map((d) => d.cache_tokens),
borderColor: chartColors.value.cache, borderColor: chartColors.value.cache,
backgroundColor: `${chartColors.value.cache}20`, backgroundColor: `${chartColors.value.cache}20`,
fill: true, fill: true,
tension: 0.3, tension: 0.3
}, }
], ]
} }
}) })
...@@ -100,7 +105,7 @@ const lineOptions = computed(() => ({ ...@@ -100,7 +105,7 @@ const lineOptions = computed(() => ({
maintainAspectRatio: false, maintainAspectRatio: false,
interaction: { interaction: {
intersect: false, intersect: false,
mode: 'index' as const, mode: 'index' as const
}, },
plugins: { plugins: {
legend: { legend: {
...@@ -111,9 +116,9 @@ const lineOptions = computed(() => ({ ...@@ -111,9 +116,9 @@ const lineOptions = computed(() => ({
pointStyle: 'circle', pointStyle: 'circle',
padding: 15, padding: 15,
font: { font: {
size: 11, size: 11
}, }
}, }
}, },
tooltip: { tooltip: {
callbacks: { callbacks: {
...@@ -127,35 +132,35 @@ const lineOptions = computed(() => ({ ...@@ -127,35 +132,35 @@ const lineOptions = computed(() => ({
return `Actual: $${formatCost(data.actual_cost)} | Standard: $${formatCost(data.cost)}` return `Actual: $${formatCost(data.actual_cost)} | Standard: $${formatCost(data.cost)}`
} }
return '' return ''
}, }
}, }
}, }
}, },
scales: { scales: {
x: { x: {
grid: { grid: {
color: chartColors.value.grid, color: chartColors.value.grid
}, },
ticks: { ticks: {
color: chartColors.value.text, color: chartColors.value.text,
font: { font: {
size: 10, size: 10
}, }
}, }
}, },
y: { y: {
grid: { grid: {
color: chartColors.value.grid, color: chartColors.value.grid
}, },
ticks: { ticks: {
color: chartColors.value.text, color: chartColors.value.text,
font: { font: {
size: 10, size: 10
}, },
callback: (value: string | number) => formatTokens(Number(value)), callback: (value: string | number) => formatTokens(Number(value))
}, }
}, }
}, }
})) }))
const formatTokens = (value: number): string => { const formatTokens = (value: number): string => {
......
...@@ -9,7 +9,7 @@ ...@@ -9,7 +9,7 @@
<button <button
@click="handleCancel" @click="handleCancel"
type="button" type="button"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-dark-700 border border-gray-300 dark:border-dark-600 rounded-md hover:bg-gray-50 dark:hover:bg-dark-600 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-dark-800 focus:ring-primary-500" class="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-200 dark:hover:bg-dark-600 dark:focus:ring-offset-dark-800"
> >
{{ cancelText }} {{ cancelText }}
</button> </button>
...@@ -17,7 +17,7 @@ ...@@ -17,7 +17,7 @@
@click="handleConfirm" @click="handleConfirm"
type="button" type="button"
:class="[ :class="[
'px-4 py-2 text-sm font-medium text-white rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-dark-800', 'rounded-md px-4 py-2 text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-dark-800',
danger danger
? 'bg-red-600 hover:bg-red-700 focus:ring-red-500' ? 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
: 'bg-primary-600 hover:bg-primary-700 focus:ring-primary-500' : 'bg-primary-600 hover:bg-primary-700 focus:ring-primary-500'
......
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
v-for="column in columns" v-for="column in columns"
:key="column.key" :key="column.key"
scope="col" scope="col"
class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-dark-400 uppercase tracking-wider" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400"
:class="{ 'cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-700': column.sortable }" :class="{ 'cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-700': column.sortable }"
@click="column.sortable && handleSort(column.key)" @click="column.sortable && handleSort(column.key)"
> >
...@@ -16,8 +16,8 @@ ...@@ -16,8 +16,8 @@
<span v-if="column.sortable" class="text-gray-400 dark:text-dark-500"> <span v-if="column.sortable" class="text-gray-400 dark:text-dark-500">
<svg <svg
v-if="sortKey === column.key" v-if="sortKey === column.key"
class="w-4 h-4" class="h-4 w-4"
:class="{ 'transform rotate-180': sortOrder === 'desc' }" :class="{ 'rotate-180 transform': sortOrder === 'desc' }"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
> >
...@@ -27,7 +27,7 @@ ...@@ -27,7 +27,7 @@
clip-rule="evenodd" clip-rule="evenodd"
/> />
</svg> </svg>
<svg v-else class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"> <svg v-else class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path <path
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
/> />
...@@ -37,22 +37,30 @@ ...@@ -37,22 +37,30 @@
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody class="bg-white dark:bg-dark-900 divide-y divide-gray-200 dark:divide-dark-700"> <tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900">
<!-- Loading skeleton --> <!-- Loading skeleton -->
<tr v-if="loading" v-for="i in 5" :key="i"> <tr v-if="loading" v-for="i in 5" :key="i">
<td v-for="column in columns" :key="column.key" class="px-6 py-4 whitespace-nowrap"> <td v-for="column in columns" :key="column.key" class="whitespace-nowrap px-6 py-4">
<div class="animate-pulse"> <div class="animate-pulse">
<div class="h-4 bg-gray-200 dark:bg-dark-700 rounded w-3/4"></div> <div class="h-4 w-3/4 rounded bg-gray-200 dark:bg-dark-700"></div>
</div> </div>
</td> </td>
</tr> </tr>
<!-- Empty state --> <!-- Empty state -->
<tr v-else-if="!data || data.length === 0"> <tr v-else-if="!data || data.length === 0">
<td :colspan="columns.length" class="px-6 py-12 text-center text-gray-500 dark:text-dark-400"> <td
:colspan="columns.length"
class="px-6 py-12 text-center text-gray-500 dark:text-dark-400"
>
<slot name="empty"> <slot name="empty">
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<svg class="w-12 h-12 text-gray-400 dark:text-dark-500 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
class="mb-4 h-12 w-12 text-gray-400 dark:text-dark-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
...@@ -60,18 +68,25 @@ ...@@ -60,18 +68,25 @@
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
/> />
</svg> </svg>
<p class="text-lg font-medium text-gray-900 dark:text-gray-100">{{ t('empty.noData') }}</p> <p class="text-lg font-medium text-gray-900 dark:text-gray-100">
{{ t('empty.noData') }}
</p>
</div> </div>
</slot> </slot>
</td> </td>
</tr> </tr>
<!-- Data rows --> <!-- Data rows -->
<tr v-else v-for="(row, index) in sortedData" :key="index" class="hover:bg-gray-50 dark:hover:bg-dark-800"> <tr
v-else
v-for="(row, index) in sortedData"
:key="index"
class="hover:bg-gray-50 dark:hover:bg-dark-800"
>
<td <td
v-for="column in columns" v-for="column in columns"
:key="column.key" :key="column.key"
class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100" class="whitespace-nowrap px-6 py-4 text-sm text-gray-900 dark:text-gray-100"
> >
<slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]"> <slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]">
{{ column.formatter ? column.formatter(row[column.key], row) : row[column.key] }} {{ column.formatter ? column.formatter(row[column.key], row) : row[column.key] }}
......
...@@ -3,14 +3,21 @@ ...@@ -3,14 +3,21 @@
<button <button
type="button" type="button"
@click="toggle" @click="toggle"
:class="[ :class="['date-picker-trigger', isOpen && 'date-picker-trigger-open']"
'date-picker-trigger',
isOpen && 'date-picker-trigger-open'
]"
> >
<span class="date-picker-icon"> <span class="date-picker-icon">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> <svg
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" /> class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
/>
</svg> </svg>
</span> </span>
<span class="date-picker-value"> <span class="date-picker-value">
...@@ -18,7 +25,7 @@ ...@@ -18,7 +25,7 @@
</span> </span>
<span class="date-picker-chevron"> <span class="date-picker-chevron">
<svg <svg
:class="['w-4 h-4 transition-transform duration-200', isOpen && 'rotate-180']" :class="['h-4 w-4 transition-transform duration-200', isOpen && 'rotate-180']"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
...@@ -30,20 +37,14 @@ ...@@ -30,20 +37,14 @@
</button> </button>
<Transition name="date-picker-dropdown"> <Transition name="date-picker-dropdown">
<div <div v-if="isOpen" class="date-picker-dropdown">
v-if="isOpen"
class="date-picker-dropdown"
>
<!-- Quick presets --> <!-- Quick presets -->
<div class="date-picker-presets"> <div class="date-picker-presets">
<button <button
v-for="preset in presets" v-for="preset in presets"
:key="preset.value" :key="preset.value"
@click="selectPreset(preset)" @click="selectPreset(preset)"
:class="[ :class="['date-picker-preset', isPresetActive(preset) && 'date-picker-preset-active']"
'date-picker-preset',
isPresetActive(preset) && 'date-picker-preset-active'
]"
> >
{{ t(preset.labelKey) }} {{ t(preset.labelKey) }}
</button> </button>
...@@ -64,8 +65,18 @@ ...@@ -64,8 +65,18 @@
/> />
</div> </div>
<div class="date-picker-separator"> <div class="date-picker-separator">
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> <svg
<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 8.25L21 12m0 0l-3.75 3.75M21 12H3" /> class="h-4 w-4 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17.25 8.25L21 12m0 0l-3.75 3.75M21 12H3"
/>
</svg> </svg>
</div> </div>
<div class="date-picker-field"> <div class="date-picker-field">
...@@ -83,10 +94,7 @@ ...@@ -83,10 +94,7 @@
<!-- Apply button --> <!-- Apply button -->
<div class="date-picker-actions"> <div class="date-picker-actions">
<button <button @click="apply" class="date-picker-apply">
@click="apply"
class="date-picker-apply"
>
{{ t('dates.apply') }} {{ t('dates.apply') }}
</button> </button>
</div> </div>
...@@ -204,7 +212,7 @@ const presets: DatePreset[] = [ ...@@ -204,7 +212,7 @@ const presets: DatePreset[] = [
const displayValue = computed(() => { const displayValue = computed(() => {
if (activePreset.value) { if (activePreset.value) {
const preset = presets.find(p => p.value === activePreset.value) const preset = presets.find((p) => p.value === activePreset.value)
if (preset) return t(preset.labelKey) if (preset) return t(preset.labelKey)
} }
...@@ -275,15 +283,21 @@ const handleEscape = (event: KeyboardEvent) => { ...@@ -275,15 +283,21 @@ const handleEscape = (event: KeyboardEvent) => {
} }
// Sync local state with props // Sync local state with props
watch(() => props.startDate, (val) => { watch(
localStartDate.value = val () => props.startDate,
onDateChange() (val) => {
}) localStartDate.value = val
onDateChange()
}
)
watch(() => props.endDate, (val) => { watch(
localEndDate.value = val () => props.endDate,
onDateChange() (val) => {
}) localEndDate.value = val
onDateChange()
}
)
onMounted(() => { onMounted(() => {
document.addEventListener('click', handleClickOutside) document.addEventListener('click', handleClickOutside)
...@@ -301,18 +315,18 @@ onUnmounted(() => { ...@@ -301,18 +315,18 @@ onUnmounted(() => {
<style scoped> <style scoped>
.date-picker-trigger { .date-picker-trigger {
@apply flex items-center gap-2; @apply flex items-center gap-2;
@apply px-3 py-2 rounded-lg text-sm; @apply rounded-lg px-3 py-2 text-sm;
@apply bg-white dark:bg-dark-800; @apply bg-white dark:bg-dark-800;
@apply border border-gray-200 dark:border-dark-600; @apply border border-gray-200 dark:border-dark-600;
@apply text-gray-700 dark:text-gray-300; @apply text-gray-700 dark:text-gray-300;
@apply transition-all duration-200; @apply transition-all duration-200;
@apply focus:outline-none focus:ring-2 focus:ring-primary-500/30 focus:border-primary-500; @apply focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30;
@apply hover:border-gray-300 dark:hover:border-dark-500; @apply hover:border-gray-300 dark:hover:border-dark-500;
@apply cursor-pointer; @apply cursor-pointer;
} }
.date-picker-trigger-open { .date-picker-trigger-open {
@apply ring-2 ring-primary-500/30 border-primary-500; @apply border-primary-500 ring-2 ring-primary-500/30;
} }
.date-picker-icon { .date-picker-icon {
...@@ -328,7 +342,7 @@ onUnmounted(() => { ...@@ -328,7 +342,7 @@ onUnmounted(() => {
} }
.date-picker-dropdown { .date-picker-dropdown {
@apply absolute z-[100] mt-2 left-0; @apply absolute left-0 z-[100] mt-2;
@apply bg-white dark:bg-dark-800; @apply bg-white dark:bg-dark-800;
@apply rounded-xl; @apply rounded-xl;
@apply border border-gray-200 dark:border-dark-700; @apply border border-gray-200 dark:border-dark-700;
...@@ -342,7 +356,7 @@ onUnmounted(() => { ...@@ -342,7 +356,7 @@ onUnmounted(() => {
} }
.date-picker-preset { .date-picker-preset {
@apply px-3 py-1.5 text-xs font-medium rounded-md; @apply rounded-md px-3 py-1.5 text-xs font-medium;
@apply text-gray-600 dark:text-gray-400; @apply text-gray-600 dark:text-gray-400;
@apply hover:bg-gray-100 dark:hover:bg-dark-700; @apply hover:bg-gray-100 dark:hover:bg-dark-700;
@apply transition-colors duration-150; @apply transition-colors duration-150;
...@@ -366,15 +380,15 @@ onUnmounted(() => { ...@@ -366,15 +380,15 @@ onUnmounted(() => {
} }
.date-picker-label { .date-picker-label {
@apply block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1; @apply mb-1 block text-xs font-medium text-gray-500 dark:text-gray-400;
} }
.date-picker-input { .date-picker-input {
@apply w-full px-2 py-1.5 text-sm rounded-md; @apply w-full rounded-md px-2 py-1.5 text-sm;
@apply bg-gray-50 dark:bg-dark-700; @apply bg-gray-50 dark:bg-dark-700;
@apply border border-gray-200 dark:border-dark-600; @apply border border-gray-200 dark:border-dark-600;
@apply text-gray-900 dark:text-gray-100; @apply text-gray-900 dark:text-gray-100;
@apply focus:outline-none focus:ring-2 focus:ring-primary-500/30 focus:border-primary-500; @apply focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30;
} }
.date-picker-input::-webkit-calendar-picker-indicator { .date-picker-input::-webkit-calendar-picker-indicator {
...@@ -395,7 +409,7 @@ onUnmounted(() => { ...@@ -395,7 +409,7 @@ onUnmounted(() => {
} }
.date-picker-apply { .date-picker-apply {
@apply px-4 py-1.5 text-sm font-medium rounded-lg; @apply rounded-lg px-4 py-1.5 text-sm font-medium;
@apply bg-primary-600 text-white; @apply bg-primary-600 text-white;
@apply hover:bg-primary-700; @apply hover:bg-primary-700;
@apply transition-colors duration-150; @apply transition-colors duration-150;
......
<template> <template>
<div class="empty-state"> <div class="empty-state">
<!-- Icon --> <!-- Icon -->
<div class="w-20 h-20 mb-5 rounded-2xl bg-gray-100 dark:bg-dark-800 flex items-center justify-center"> <div
class="mb-5 flex h-20 w-20 items-center justify-center rounded-2xl bg-gray-100 dark:bg-dark-800"
>
<slot name="icon"> <slot name="icon">
<component <component v-if="icon" :is="icon" class="empty-state-icon h-10 w-10" aria-hidden="true" />
v-if="icon"
:is="icon"
class="empty-state-icon w-10 h-10"
aria-hidden="true"
/>
<svg <svg
v-else v-else
class="empty-state-icon w-10 h-10" class="empty-state-icon h-10 w-10"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
...@@ -48,17 +45,13 @@ ...@@ -48,17 +45,13 @@
> >
<svg <svg
v-if="actionIcon" v-if="actionIcon"
class="w-5 h-5 mr-2" class="mr-2 h-5 w-5"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke-width="1.5" stroke-width="1.5"
> >
<path <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
stroke-linecap="round"
stroke-linejoin="round"
d="M12 4.5v15m7.5-7.5h-15"
/>
</svg> </svg>
{{ actionText }} {{ actionText }}
</component> </component>
......
<template> <template>
<span <span
:class="[ :class="[
'inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md text-xs font-medium transition-colors', 'inline-flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium transition-colors',
badgeClass badgeClass
]" ]"
> >
...@@ -10,10 +10,7 @@ ...@@ -10,10 +10,7 @@
<!-- Group name --> <!-- Group name -->
<span class="truncate">{{ name }}</span> <span class="truncate">{{ name }}</span>
<!-- Right side label --> <!-- Right side label -->
<span <span v-if="showLabel" :class="labelClass">
v-if="showLabel"
:class="labelClass"
>
{{ labelText }} {{ labelText }}
</span> </span>
</span> </span>
...@@ -31,7 +28,7 @@ interface Props { ...@@ -31,7 +28,7 @@ interface Props {
subscriptionType?: SubscriptionType subscriptionType?: SubscriptionType
rateMultiplier?: number rateMultiplier?: number
showRate?: boolean showRate?: boolean
daysRemaining?: number | null // 剩余天数(订阅类型时使用) daysRemaining?: number | null // 剩余天数(订阅类型时使用)
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
...@@ -97,6 +94,9 @@ const labelClass = computed(() => { ...@@ -97,6 +94,9 @@ const labelClass = computed(() => {
if (props.platform === 'openai') { if (props.platform === 'openai') {
return `${base} bg-emerald-200/60 text-emerald-800 dark:bg-emerald-800/40 dark:text-emerald-300` return `${base} bg-emerald-200/60 text-emerald-800 dark:bg-emerald-800/40 dark:text-emerald-300`
} }
if (props.platform === 'gemini') {
return `${base} bg-blue-200/60 text-blue-800 dark:bg-blue-800/40 dark:text-blue-300`
}
return `${base} bg-violet-200/60 text-violet-800 dark:bg-violet-800/40 dark:text-violet-300` return `${base} bg-violet-200/60 text-violet-800 dark:bg-violet-800/40 dark:text-violet-300`
}) })
...@@ -113,6 +113,11 @@ const badgeClass = computed(() => { ...@@ -113,6 +113,11 @@ const badgeClass = computed(() => {
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
: 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400' : 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400'
} }
if (props.platform === 'gemini') {
return isSubscription.value
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-sky-50 text-sky-700 dark:bg-sky-900/20 dark:text-sky-400'
}
// Fallback: original colors // Fallback: original colors
return isSubscription.value return isSubscription.value
? 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400' ? 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400'
......
...@@ -2,15 +2,15 @@ ...@@ -2,15 +2,15 @@
<div> <div>
<label class="input-label"> <label class="input-label">
Groups Groups
<span class="text-gray-400 font-normal">({{ modelValue.length }} selected)</span> <span class="font-normal text-gray-400">({{ modelValue.length }} selected)</span>
</label> </label>
<div <div
class="grid grid-cols-2 gap-1 max-h-32 overflow-y-auto p-2 border border-gray-200 dark:border-dark-600 rounded-lg bg-gray-50 dark:bg-dark-800" class="grid max-h-32 grid-cols-2 gap-1 overflow-y-auto rounded-lg border border-gray-200 bg-gray-50 p-2 dark:border-dark-600 dark:bg-dark-800"
> >
<label <label
v-for="group in filteredGroups" v-for="group in filteredGroups"
:key="group.id" :key="group.id"
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-white dark:hover:bg-dark-700 cursor-pointer transition-colors" class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-white dark:hover:bg-dark-700"
:title="`${group.rate_multiplier}x rate · ${group.account_count || 0} accounts`" :title="`${group.rate_multiplier}x rate · ${group.account_count || 0} accounts`"
> >
<input <input
...@@ -18,19 +18,19 @@ ...@@ -18,19 +18,19 @@
:value="group.id" :value="group.id"
:checked="modelValue.includes(group.id)" :checked="modelValue.includes(group.id)"
@change="handleChange(group.id, ($event.target as HTMLInputElement).checked)" @change="handleChange(group.id, ($event.target as HTMLInputElement).checked)"
class="w-3.5 h-3.5 text-primary-500 border-gray-300 dark:border-dark-500 rounded focus:ring-primary-500 shrink-0" class="h-3.5 w-3.5 shrink-0 rounded border-gray-300 text-primary-500 focus:ring-primary-500 dark:border-dark-500"
/> />
<GroupBadge <GroupBadge
:name="group.name" :name="group.name"
:subscription-type="group.subscription_type" :subscription-type="group.subscription_type"
:rate-multiplier="group.rate_multiplier" :rate-multiplier="group.rate_multiplier"
class="flex-1 min-w-0" class="min-w-0 flex-1"
/> />
<span class="text-xs text-gray-400 shrink-0">{{ group.account_count || 0 }}</span> <span class="shrink-0 text-xs text-gray-400">{{ group.account_count || 0 }}</span>
</label> </label>
<div <div
v-if="filteredGroups.length === 0" v-if="filteredGroups.length === 0"
class="col-span-2 text-center text-sm text-gray-500 dark:text-gray-400 py-2" class="col-span-2 py-2 text-center text-sm text-gray-500 dark:text-gray-400"
> >
No groups available No groups available
</div> </div>
...@@ -59,13 +59,13 @@ const filteredGroups = computed(() => { ...@@ -59,13 +59,13 @@ const filteredGroups = computed(() => {
if (!props.platform) { if (!props.platform) {
return props.groups return props.groups
} }
return props.groups.filter(g => g.platform === props.platform) return props.groups.filter((g) => g.platform === props.platform)
}) })
const handleChange = (groupId: number, checked: boolean) => { const handleChange = (groupId: number, checked: boolean) => {
const newValue = checked const newValue = checked
? [...props.modelValue, groupId] ? [...props.modelValue, groupId]
: props.modelValue.filter(id => id !== groupId) : props.modelValue.filter((id) => id !== groupId)
emit('update:modelValue', newValue) emit('update:modelValue', newValue)
} }
</script> </script>
...@@ -2,13 +2,13 @@ ...@@ -2,13 +2,13 @@
<div class="relative" ref="dropdownRef"> <div class="relative" ref="dropdownRef">
<button <button
@click="toggleDropdown" @click="toggleDropdown"
class="flex items-center gap-1.5 px-2 py-1.5 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-dark-700 transition-colors" class="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
:title="currentLocale?.name" :title="currentLocale?.name"
> >
<span class="text-base">{{ currentLocale?.flag }}</span> <span class="text-base">{{ currentLocale?.flag }}</span>
<span class="hidden sm:inline">{{ currentLocale?.code.toUpperCase() }}</span> <span class="hidden sm:inline">{{ currentLocale?.code.toUpperCase() }}</span>
<svg <svg
class="w-3.5 h-3.5 text-gray-400 transition-transform duration-200" class="h-3.5 w-3.5 text-gray-400 transition-transform duration-200"
:class="{ 'rotate-180': isOpen }" :class="{ 'rotate-180': isOpen }"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
...@@ -22,20 +22,23 @@ ...@@ -22,20 +22,23 @@
<transition name="dropdown"> <transition name="dropdown">
<div <div
v-if="isOpen" v-if="isOpen"
class="absolute right-0 mt-1 w-32 rounded-lg bg-white dark:bg-dark-800 shadow-lg border border-gray-200 dark:border-dark-700 overflow-hidden z-50" class="absolute right-0 z-50 mt-1 w-32 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg dark:border-dark-700 dark:bg-dark-800"
> >
<button <button
v-for="locale in availableLocales" v-for="locale in availableLocales"
:key="locale.code" :key="locale.code"
@click="selectLocale(locale.code)" @click="selectLocale(locale.code)"
class="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-dark-700 transition-colors" class="flex w-full items-center gap-2 px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-dark-700"
:class="{ 'bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400': locale.code === currentLocaleCode }" :class="{
'bg-primary-50 text-primary-600 dark:bg-primary-900/20 dark:text-primary-400':
locale.code === currentLocaleCode
}"
> >
<span class="text-base">{{ locale.flag }}</span> <span class="text-base">{{ locale.flag }}</span>
<span>{{ locale.name }}</span> <span>{{ locale.name }}</span>
<svg <svg
v-if="locale.code === currentLocaleCode" v-if="locale.code === currentLocaleCode"
class="w-4 h-4 ml-auto text-primary-500" class="ml-auto h-4 w-4 text-primary-500"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
...@@ -60,7 +63,7 @@ const isOpen = ref(false) ...@@ -60,7 +63,7 @@ const isOpen = ref(false)
const dropdownRef = ref<HTMLElement | null>(null) const dropdownRef = ref<HTMLElement | null>(null)
const currentLocaleCode = computed(() => locale.value) const currentLocaleCode = computed(() => locale.value)
const currentLocale = computed(() => availableLocales.find(l => l.code === locale.value)) const currentLocale = computed(() => availableLocales.find((l) => l.code === locale.value))
function toggleDropdown() { function toggleDropdown() {
isOpen.value = !isOpen.value isOpen.value = !isOpen.value
......
...@@ -9,24 +9,24 @@ ...@@ -9,24 +9,24 @@
@click.self="handleClose" @click.self="handleClose"
> >
<!-- Modal panel --> <!-- Modal panel -->
<div <div :class="['modal-content', sizeClasses]" @click.stop>
:class="['modal-content', sizeClasses]"
@click.stop
>
<!-- Header --> <!-- Header -->
<div class="modal-header"> <div class="modal-header">
<h3 <h3 id="modal-title" class="modal-title">
id="modal-title"
class="modal-title"
>
{{ title }} {{ title }}
</h3> </h3>
<button <button
@click="emit('close')" @click="emit('close')"
class="p-2 -mr-2 rounded-xl text-gray-400 dark:text-dark-500 hover:text-gray-600 dark:hover:text-dark-300 hover:bg-gray-100 dark:hover:bg-dark-700 transition-colors" class="-mr-2 rounded-xl p-2 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:text-dark-500 dark:hover:bg-dark-700 dark:hover:text-dark-300"
aria-label="Close modal" aria-label="Close modal"
> >
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> <svg
class="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
...@@ -38,10 +38,7 @@ ...@@ -38,10 +38,7 @@
</div> </div>
<!-- Footer --> <!-- Footer -->
<div <div v-if="$slots.footer" class="modal-footer">
v-if="$slots.footer"
class="modal-footer"
>
<slot name="footer"></slot> <slot name="footer"></slot>
</div> </div>
</div> </div>
......
<template> <template>
<div class="flex items-center justify-between px-4 py-3 bg-white dark:bg-dark-800 border-t border-gray-200 dark:border-dark-700 sm:px-6"> <div
<div class="flex items-center justify-between flex-1 sm:hidden"> class="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 dark:border-dark-700 dark:bg-dark-800 sm:px-6"
>
<div class="flex flex-1 items-center justify-between sm:hidden">
<!-- Mobile pagination --> <!-- Mobile pagination -->
<button <button
@click="goToPage(page - 1)" @click="goToPage(page - 1)"
:disabled="page === 1" :disabled="page === 1"
class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-dark-700 border border-gray-300 dark:border-dark-600 rounded-md hover:bg-gray-50 dark:hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed" class="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-200 dark:hover:bg-dark-600"
> >
{{ t('pagination.previous') }} {{ t('pagination.previous') }}
</button> </button>
...@@ -15,7 +17,7 @@ ...@@ -15,7 +17,7 @@
<button <button
@click="goToPage(page + 1)" @click="goToPage(page + 1)"
:disabled="page === totalPages" :disabled="page === totalPages"
class="relative inline-flex items-center px-4 py-2 ml-3 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-dark-700 border border-gray-300 dark:border-dark-600 rounded-md hover:bg-gray-50 dark:hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed" class="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-200 dark:hover:bg-dark-600"
> >
{{ t('pagination.next') }} {{ t('pagination.next') }}
</button> </button>
...@@ -36,8 +38,10 @@ ...@@ -36,8 +38,10 @@
<!-- Page size selector --> <!-- Page size selector -->
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('pagination.perPage') }}:</span> <span class="text-sm text-gray-700 dark:text-gray-300"
<div class="w-20 page-size-select"> >{{ t('pagination.perPage') }}:</span
>
<div class="page-size-select w-20">
<Select <Select
:model-value="pageSize" :model-value="pageSize"
:options="pageSizeSelectOptions" :options="pageSizeSelectOptions"
...@@ -56,10 +60,10 @@ ...@@ -56,10 +60,10 @@
<button <button
@click="goToPage(page - 1)" @click="goToPage(page - 1)"
:disabled="page === 1" :disabled="page === 1"
class="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 bg-white dark:bg-dark-700 border border-gray-300 dark:border-dark-600 rounded-l-md hover:bg-gray-50 dark:hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed" class="relative inline-flex items-center rounded-l-md border border-gray-300 bg-white px-2 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600"
:aria-label="t('pagination.previous')" :aria-label="t('pagination.previous')"
> >
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> <svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path <path
fill-rule="evenodd" fill-rule="evenodd"
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
...@@ -75,13 +79,15 @@ ...@@ -75,13 +79,15 @@
@click="typeof pageNum === 'number' && goToPage(pageNum)" @click="typeof pageNum === 'number' && goToPage(pageNum)"
:disabled="typeof pageNum !== 'number'" :disabled="typeof pageNum !== 'number'"
:class="[ :class="[
'relative inline-flex items-center px-4 py-2 text-sm font-medium border', 'relative inline-flex items-center border px-4 py-2 text-sm font-medium',
pageNum === page pageNum === page
? 'z-10 bg-primary-50 dark:bg-primary-900/30 border-primary-500 text-primary-600 dark:text-primary-400' ? 'z-10 border-primary-500 bg-primary-50 text-primary-600 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-white dark:bg-dark-700 border-gray-300 dark:border-dark-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-dark-600', : 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600',
typeof pageNum !== 'number' && 'cursor-default' typeof pageNum !== 'number' && 'cursor-default'
]" ]"
:aria-label="typeof pageNum === 'number' ? t('pagination.goToPage', { page: pageNum }) : undefined" :aria-label="
typeof pageNum === 'number' ? t('pagination.goToPage', { page: pageNum }) : undefined
"
:aria-current="pageNum === page ? 'page' : undefined" :aria-current="pageNum === page ? 'page' : undefined"
> >
{{ pageNum }} {{ pageNum }}
...@@ -91,10 +97,10 @@ ...@@ -91,10 +97,10 @@
<button <button
@click="goToPage(page + 1)" @click="goToPage(page + 1)"
:disabled="page === totalPages" :disabled="page === totalPages"
class="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 bg-white dark:bg-dark-700 border border-gray-300 dark:border-dark-600 rounded-r-md hover:bg-gray-50 dark:hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed" class="relative inline-flex items-center rounded-r-md border border-gray-300 bg-white px-2 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600"
:aria-label="t('pagination.next')" :aria-label="t('pagination.next')"
> >
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> <svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path <path
fill-rule="evenodd" fill-rule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
...@@ -145,7 +151,7 @@ const toItem = computed(() => { ...@@ -145,7 +151,7 @@ const toItem = computed(() => {
}) })
const pageSizeSelectOptions = computed(() => { const pageSizeSelectOptions = computed(() => {
return props.pageSizeOptions.map(size => ({ return props.pageSizeOptions.map((size) => ({
value: size, value: size,
label: String(size) label: String(size)
})) }))
...@@ -209,6 +215,6 @@ const handlePageSizeChange = (value: string | number | null) => { ...@@ -209,6 +215,6 @@ const handlePageSizeChange = (value: string | number | null) => {
<style scoped> <style scoped>
.page-size-select :deep(.select-trigger) { .page-size-select :deep(.select-trigger) {
@apply py-1.5 px-3 text-sm; @apply px-3 py-1.5 text-sm;
} }
</style> </style>
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment