style(frontend): 优化 Components 代码风格和结构
- 统一移除语句末尾分号,规范代码格式 - 优化组件类型定义和 props 声明 - 改进组件文档和示例代码 - 提升代码可读性和一致性
This commit is contained in:
@@ -5,158 +5,164 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
interface TurnstileRenderOptions {
|
||||
sitekey: string;
|
||||
callback: (token: string) => void;
|
||||
'expired-callback'?: () => void;
|
||||
'error-callback'?: () => void;
|
||||
theme?: 'light' | 'dark' | 'auto';
|
||||
size?: 'normal' | 'compact' | 'flexible';
|
||||
sitekey: string
|
||||
callback: (token: string) => void
|
||||
'expired-callback'?: () => void
|
||||
'error-callback'?: () => void
|
||||
theme?: 'light' | 'dark' | 'auto'
|
||||
size?: 'normal' | 'compact' | 'flexible'
|
||||
}
|
||||
|
||||
interface TurnstileAPI {
|
||||
render: (container: HTMLElement, options: TurnstileRenderOptions) => string;
|
||||
reset: (widgetId?: string) => void;
|
||||
remove: (widgetId?: string) => void;
|
||||
render: (container: HTMLElement, options: TurnstileRenderOptions) => string
|
||||
reset: (widgetId?: string) => void
|
||||
remove: (widgetId?: string) => void
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: TurnstileAPI;
|
||||
onTurnstileLoad?: () => void;
|
||||
turnstile?: TurnstileAPI
|
||||
onTurnstileLoad?: () => void
|
||||
}
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
siteKey: string;
|
||||
theme?: 'light' | 'dark' | 'auto';
|
||||
size?: 'normal' | 'compact' | 'flexible';
|
||||
}>(), {
|
||||
theme: 'auto',
|
||||
size: 'flexible',
|
||||
});
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
siteKey: string
|
||||
theme?: 'light' | 'dark' | 'auto'
|
||||
size?: 'normal' | 'compact' | 'flexible'
|
||||
}>(),
|
||||
{
|
||||
theme: 'auto',
|
||||
size: 'flexible'
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'verify', token: string): void;
|
||||
(e: 'expire'): void;
|
||||
(e: 'error'): void;
|
||||
}>();
|
||||
(e: 'verify', token: string): void
|
||||
(e: 'expire'): void
|
||||
(e: 'error'): void
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
const widgetId = ref<string | null>(null);
|
||||
const scriptLoaded = ref(false);
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const widgetId = ref<string | null>(null)
|
||||
const scriptLoaded = ref(false)
|
||||
|
||||
const loadScript = (): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (window.turnstile) {
|
||||
scriptLoaded.value = true;
|
||||
resolve();
|
||||
return;
|
||||
scriptLoaded.value = true
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if script is already loading
|
||||
const existingScript = document.querySelector('script[src*="turnstile"]');
|
||||
const existingScript = document.querySelector('script[src*="turnstile"]')
|
||||
if (existingScript) {
|
||||
window.onTurnstileLoad = () => {
|
||||
scriptLoaded.value = true;
|
||||
resolve();
|
||||
};
|
||||
return;
|
||||
scriptLoaded.value = true
|
||||
resolve()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad'
|
||||
script.async = true
|
||||
script.defer = true
|
||||
|
||||
window.onTurnstileLoad = () => {
|
||||
scriptLoaded.value = true;
|
||||
resolve();
|
||||
};
|
||||
scriptLoaded.value = true
|
||||
resolve()
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
if (!window.turnstile || !containerRef.value || !props.siteKey) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
// Remove existing widget if any
|
||||
if (widgetId.value) {
|
||||
try {
|
||||
window.turnstile.remove(widgetId.value);
|
||||
window.turnstile.remove(widgetId.value)
|
||||
} catch {
|
||||
// Ignore errors when removing
|
||||
}
|
||||
widgetId.value = null;
|
||||
widgetId.value = null
|
||||
}
|
||||
|
||||
// Clear container
|
||||
containerRef.value.innerHTML = '';
|
||||
containerRef.value.innerHTML = ''
|
||||
|
||||
widgetId.value = window.turnstile.render(containerRef.value, {
|
||||
sitekey: props.siteKey,
|
||||
callback: (token: string) => {
|
||||
emit('verify', token);
|
||||
emit('verify', token)
|
||||
},
|
||||
'expired-callback': () => {
|
||||
emit('expire');
|
||||
emit('expire')
|
||||
},
|
||||
'error-callback': () => {
|
||||
emit('error');
|
||||
emit('error')
|
||||
},
|
||||
theme: props.theme,
|
||||
size: props.size,
|
||||
});
|
||||
};
|
||||
size: props.size
|
||||
})
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
if (window.turnstile && widgetId.value) {
|
||||
window.turnstile.reset(widgetId.value);
|
||||
window.turnstile.reset(widgetId.value)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Expose reset method to parent
|
||||
defineExpose({ reset });
|
||||
defineExpose({ reset })
|
||||
|
||||
onMounted(async () => {
|
||||
if (!props.siteKey) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await loadScript();
|
||||
renderWidget();
|
||||
await loadScript()
|
||||
renderWidget()
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize Turnstile:', error);
|
||||
emit('error');
|
||||
console.error('Failed to initialize Turnstile:', error)
|
||||
emit('error')
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (window.turnstile && widgetId.value) {
|
||||
try {
|
||||
window.turnstile.remove(widgetId.value);
|
||||
window.turnstile.remove(widgetId.value)
|
||||
} catch {
|
||||
// Ignore errors when removing
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
// Re-render when siteKey changes
|
||||
watch(() => props.siteKey, (newKey) => {
|
||||
if (newKey && scriptLoaded.value) {
|
||||
renderWidget();
|
||||
watch(
|
||||
() => props.siteKey,
|
||||
(newKey) => {
|
||||
if (newKey && scriptLoaded.value) {
|
||||
renderWidget()
|
||||
}
|
||||
}
|
||||
});
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
<template>
|
||||
<Modal
|
||||
:show="show"
|
||||
:title="t('admin.accounts.usageStatistics')"
|
||||
size="2xl"
|
||||
@close="handleClose"
|
||||
>
|
||||
<Modal :show="show" :title="t('admin.accounts.usageStatistics')" size="2xl" @close="handleClose">
|
||||
<div class="space-y-6">
|
||||
<!-- Account Info Header -->
|
||||
<div v-if="account" class="flex items-center justify-between p-3 bg-gradient-to-r from-primary-50 to-primary-100 dark:from-primary-900/20 dark:to-primary-800/20 rounded-xl border border-primary-200 dark:border-primary-700/50">
|
||||
<div
|
||||
v-if="account"
|
||||
class="flex items-center justify-between rounded-xl border border-primary-200 bg-gradient-to-r from-primary-50 to-primary-100 p-3 dark:border-primary-700/50 dark:from-primary-900/20 dark:to-primary-800/20"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-primary-600"
|
||||
>
|
||||
<svg class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
@@ -23,7 +28,7 @@
|
||||
</div>
|
||||
<span
|
||||
: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'
|
||||
? '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'
|
||||
@@ -42,62 +47,140 @@
|
||||
<!-- Row 1: Main Stats Cards -->
|
||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<!-- 30-Day Total Cost -->
|
||||
<div class="card p-4 bg-gradient-to-br from-emerald-50 to-white dark:from-emerald-900/10 dark:to-dark-700 border-emerald-200 dark:border-emerald-800/30">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.totalCost') }}</span>
|
||||
<div class="p-1.5 rounded-lg bg-emerald-100 dark:bg-emerald-900/30">
|
||||
<svg class="w-4 h-4 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<div
|
||||
class="card border-emerald-200 bg-gradient-to-br from-emerald-50 to-white p-4 dark:border-emerald-800/30 dark:from-emerald-900/10 dark:to-dark-700"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.totalCost')
|
||||
}}</span>
|
||||
<div class="rounded-lg bg-emerald-100 p-1.5 dark:bg-emerald-900/30">
|
||||
<svg
|
||||
class="h-4 w-4 text-emerald-600 dark:text-emerald-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">${{ formatCost(stats.summary.total_cost) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
${{ formatCost(stats.summary.total_cost) }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.stats.accumulatedCost') }}
|
||||
<span class="text-gray-400 dark:text-gray-500">({{ t('admin.accounts.stats.standardCost') }}: ${{ formatCost(stats.summary.total_standard_cost) }})</span>
|
||||
<span class="text-gray-400 dark:text-gray-500"
|
||||
>({{ t('admin.accounts.stats.standardCost') }}: ${{
|
||||
formatCost(stats.summary.total_standard_cost)
|
||||
}})</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 30-Day Total Requests -->
|
||||
<div class="card p-4 bg-gradient-to-br from-blue-50 to-white dark:from-blue-900/10 dark:to-dark-700 border-blue-200 dark:border-blue-800/30">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.totalRequests') }}</span>
|
||||
<div class="p-1.5 rounded-lg bg-blue-100 dark:bg-blue-900/30">
|
||||
<svg class="w-4 h-4 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
<div
|
||||
class="card border-blue-200 bg-gradient-to-br from-blue-50 to-white p-4 dark:border-blue-800/30 dark:from-blue-900/10 dark:to-dark-700"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.totalRequests')
|
||||
}}</span>
|
||||
<div class="rounded-lg bg-blue-100 p-1.5 dark:bg-blue-900/30">
|
||||
<svg
|
||||
class="h-4 w-4 text-blue-600 dark:text-blue-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ formatNumber(stats.summary.total_requests) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.accounts.stats.totalCalls') }}</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ formatNumber(stats.summary.total_requests) }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.stats.totalCalls') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Daily Average Cost -->
|
||||
<div class="card p-4 bg-gradient-to-br from-amber-50 to-white dark:from-amber-900/10 dark:to-dark-700 border-amber-200 dark:border-amber-800/30">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.avgDailyCost') }}</span>
|
||||
<div class="p-1.5 rounded-lg bg-amber-100 dark:bg-amber-900/30">
|
||||
<svg class="w-4 h-4 text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
<div
|
||||
class="card border-amber-200 bg-gradient-to-br from-amber-50 to-white p-4 dark:border-amber-800/30 dark:from-amber-900/10 dark:to-dark-700"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.avgDailyCost')
|
||||
}}</span>
|
||||
<div class="rounded-lg bg-amber-100 p-1.5 dark:bg-amber-900/30">
|
||||
<svg
|
||||
class="h-4 w-4 text-amber-600 dark:text-amber-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">${{ formatCost(stats.summary.avg_daily_cost) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.accounts.stats.basedOnActualDays', { days: stats.summary.actual_days_used }) }}</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
${{ formatCost(stats.summary.avg_daily_cost) }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
t('admin.accounts.stats.basedOnActualDays', {
|
||||
days: stats.summary.actual_days_used
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Daily Average Requests -->
|
||||
<div class="card p-4 bg-gradient-to-br from-purple-50 to-white dark:from-purple-900/10 dark:to-dark-700 border-purple-200 dark:border-purple-800/30">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.avgDailyRequests') }}</span>
|
||||
<div class="p-1.5 rounded-lg bg-purple-100 dark:bg-purple-900/30">
|
||||
<svg class="w-4 h-4 text-purple-600 dark:text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" />
|
||||
<div
|
||||
class="card border-purple-200 bg-gradient-to-br from-purple-50 to-white p-4 dark:border-purple-800/30 dark:from-purple-900/10 dark:to-dark-700"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.avgDailyRequests')
|
||||
}}</span>
|
||||
<div class="rounded-lg bg-purple-100 p-1.5 dark:bg-purple-900/30">
|
||||
<svg
|
||||
class="h-4 w-4 text-purple-600 dark:text-purple-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ formatNumber(Math.round(stats.summary.avg_daily_requests)) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.accounts.stats.avgDailyUsage') }}</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ formatNumber(Math.round(stats.summary.avg_daily_requests)) }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.stats.avgDailyUsage') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -105,78 +188,148 @@
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<!-- Today Overview -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="p-1.5 rounded-lg bg-cyan-100 dark:bg-cyan-900/30">
|
||||
<svg class="w-4 h-4 text-cyan-600 dark:text-cyan-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<div class="rounded-lg bg-cyan-100 p-1.5 dark:bg-cyan-900/30">
|
||||
<svg
|
||||
class="h-4 w-4 text-cyan-600 dark:text-cyan-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.accounts.stats.todayOverview') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
t('admin.accounts.stats.todayOverview')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.cost') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">${{ formatCost(stats.summary.today?.cost || 0) }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.cost')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white"
|
||||
>${{ formatCost(stats.summary.today?.cost || 0) }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.requests') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ formatNumber(stats.summary.today?.requests || 0) }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.requests')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
formatNumber(stats.summary.today?.requests || 0)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">Tokens</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ formatTokens(stats.summary.today?.tokens || 0) }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
formatTokens(stats.summary.today?.tokens || 0)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Highest Cost Day -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="p-1.5 rounded-lg bg-orange-100 dark:bg-orange-900/30">
|
||||
<svg class="w-4 h-4 text-orange-600 dark:text-orange-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z" />
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<div class="rounded-lg bg-orange-100 p-1.5 dark:bg-orange-900/30">
|
||||
<svg
|
||||
class="h-4 w-4 text-orange-600 dark:text-orange-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.accounts.stats.highestCostDay') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
t('admin.accounts.stats.highestCostDay')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.date') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ stats.summary.highest_cost_day?.label || '-' }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.date')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
stats.summary.highest_cost_day?.label || '-'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.cost') }}</span>
|
||||
<span class="text-sm font-semibold text-orange-600 dark:text-orange-400">${{ formatCost(stats.summary.highest_cost_day?.cost || 0) }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.cost')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-orange-600 dark:text-orange-400"
|
||||
>${{ formatCost(stats.summary.highest_cost_day?.cost || 0) }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.requests') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ formatNumber(stats.summary.highest_cost_day?.requests || 0) }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.requests')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
formatNumber(stats.summary.highest_cost_day?.requests || 0)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Highest Request Day -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="p-1.5 rounded-lg bg-indigo-100 dark:bg-indigo-900/30">
|
||||
<svg class="w-4 h-4 text-indigo-600 dark:text-indigo-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<div class="rounded-lg bg-indigo-100 p-1.5 dark:bg-indigo-900/30">
|
||||
<svg
|
||||
class="h-4 w-4 text-indigo-600 dark:text-indigo-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.accounts.stats.highestRequestDay') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
t('admin.accounts.stats.highestRequestDay')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.date') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ stats.summary.highest_request_day?.label || '-' }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.date')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
stats.summary.highest_request_day?.label || '-'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.requests') }}</span>
|
||||
<span class="text-sm font-semibold text-indigo-600 dark:text-indigo-400">{{ formatNumber(stats.summary.highest_request_day?.requests || 0) }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.requests')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-indigo-600 dark:text-indigo-400">{{
|
||||
formatNumber(stats.summary.highest_request_day?.requests || 0)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.cost') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">${{ formatCost(stats.summary.highest_request_day?.cost || 0) }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.cost')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white"
|
||||
>${{ formatCost(stats.summary.highest_request_day?.cost || 0) }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -186,70 +339,134 @@
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<!-- Accumulated Tokens -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="p-1.5 rounded-lg bg-teal-100 dark:bg-teal-900/30">
|
||||
<svg class="w-4 h-4 text-teal-600 dark:text-teal-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<div class="rounded-lg bg-teal-100 p-1.5 dark:bg-teal-900/30">
|
||||
<svg
|
||||
class="h-4 w-4 text-teal-600 dark:text-teal-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.accounts.stats.accumulatedTokens') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
t('admin.accounts.stats.accumulatedTokens')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.totalTokens') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ formatTokens(stats.summary.total_tokens) }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.totalTokens')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
formatTokens(stats.summary.total_tokens)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.dailyAvgTokens') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ formatTokens(Math.round(stats.summary.avg_daily_tokens)) }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.dailyAvgTokens')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
formatTokens(Math.round(stats.summary.avg_daily_tokens))
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Performance -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="p-1.5 rounded-lg bg-rose-100 dark:bg-rose-900/30">
|
||||
<svg class="w-4 h-4 text-rose-600 dark:text-rose-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<div class="rounded-lg bg-rose-100 p-1.5 dark:bg-rose-900/30">
|
||||
<svg
|
||||
class="h-4 w-4 text-rose-600 dark:text-rose-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.accounts.stats.performance') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
t('admin.accounts.stats.performance')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.avgResponseTime') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ formatDuration(stats.summary.avg_duration_ms) }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.avgResponseTime')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
formatDuration(stats.summary.avg_duration_ms)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.daysActive') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ stats.summary.actual_days_used }} / {{ stats.summary.days }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.daysActive')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white"
|
||||
>{{ stats.summary.actual_days_used }} / {{ stats.summary.days }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="p-1.5 rounded-lg bg-lime-100 dark:bg-lime-900/30">
|
||||
<svg class="w-4 h-4 text-lime-600 dark:text-lime-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<div class="rounded-lg bg-lime-100 p-1.5 dark:bg-lime-900/30">
|
||||
<svg
|
||||
class="h-4 w-4 text-lime-600 dark:text-lime-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.accounts.stats.recentActivity') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
t('admin.accounts.stats.recentActivity')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.todayRequests') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ formatNumber(stats.summary.today?.requests || 0) }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.todayRequests')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
formatNumber(stats.summary.today?.requests || 0)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.todayTokens') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ formatTokens(stats.summary.today?.tokens || 0) }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.todayTokens')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
formatTokens(stats.summary.today?.tokens || 0)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.todayCost') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">${{ formatCost(stats.summary.today?.cost || 0) }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.todayCost')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white"
|
||||
>${{ formatCost(stats.summary.today?.cost || 0) }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -257,26 +474,36 @@
|
||||
|
||||
<!-- Usage Trend Chart -->
|
||||
<div class="card p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">{{ t('admin.accounts.stats.usageTrend') }}</h3>
|
||||
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.accounts.stats.usageTrend') }}
|
||||
</h3>
|
||||
<div class="h-64">
|
||||
<Line v-if="trendChartData" :data="trendChartData" :options="lineChartOptions" />
|
||||
<div v-else class="flex items-center justify-center h-full text-gray-500 dark:text-gray-400 text-sm">
|
||||
<div
|
||||
v-else
|
||||
class="flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ t('admin.dashboard.noDataAvailable') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model Distribution -->
|
||||
<ModelDistributionChart
|
||||
:model-stats="stats.models"
|
||||
:loading="false"
|
||||
/>
|
||||
<ModelDistributionChart :model-stats="stats.models" :loading="false" />
|
||||
</template>
|
||||
|
||||
<!-- No Data State -->
|
||||
<div v-else-if="!loading" class="flex flex-col items-center justify-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<svg class="w-12 h-12 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
<div
|
||||
v-else-if="!loading"
|
||||
class="flex flex-col items-center justify-center py-12 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<svg class="mb-4 h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-sm">{{ t('admin.accounts.stats.noData') }}</p>
|
||||
</div>
|
||||
@@ -286,7 +513,7 @@
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
@click="handleClose"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-dark-600 hover:bg-gray-200 dark:hover:bg-dark-500 rounded-lg transition-colors"
|
||||
class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
|
||||
>
|
||||
{{ t('common.close') }}
|
||||
</button>
|
||||
@@ -349,7 +576,7 @@ const isDarkMode = computed(() => {
|
||||
// Chart colors
|
||||
const chartColors = computed(() => ({
|
||||
text: isDarkMode.value ? '#e5e7eb' : '#374151',
|
||||
grid: isDarkMode.value ? '#374151' : '#e5e7eb',
|
||||
grid: isDarkMode.value ? '#374151' : '#e5e7eb'
|
||||
}))
|
||||
|
||||
// Line chart data
|
||||
@@ -357,27 +584,27 @@ const trendChartData = computed(() => {
|
||||
if (!stats.value?.history?.length) return null
|
||||
|
||||
return {
|
||||
labels: stats.value.history.map(h => h.label),
|
||||
labels: stats.value.history.map((h) => h.label),
|
||||
datasets: [
|
||||
{
|
||||
label: t('admin.accounts.stats.cost') + ' (USD)',
|
||||
data: stats.value.history.map(h => h.cost),
|
||||
data: stats.value.history.map((h) => h.cost),
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
yAxisID: 'y',
|
||||
yAxisID: 'y'
|
||||
},
|
||||
{
|
||||
label: t('admin.accounts.stats.requests'),
|
||||
data: stats.value.history.map(h => h.requests),
|
||||
data: stats.value.history.map((h) => h.requests),
|
||||
borderColor: '#f97316',
|
||||
backgroundColor: 'rgba(249, 115, 22, 0.1)',
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
yAxisID: 'y1',
|
||||
},
|
||||
],
|
||||
yAxisID: 'y1'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
@@ -387,7 +614,7 @@ const lineChartOptions = computed(() => ({
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index' as const,
|
||||
mode: 'index' as const
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
@@ -398,9 +625,9 @@ const lineChartOptions = computed(() => ({
|
||||
pointStyle: 'circle',
|
||||
padding: 15,
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
size: 11
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
@@ -411,81 +638,84 @@ const lineChartOptions = computed(() => ({
|
||||
return `${label}: $${formatCost(value)}`
|
||||
}
|
||||
return `${label}: ${formatNumber(value)}`
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: chartColors.value.grid,
|
||||
color: chartColors.value.grid
|
||||
},
|
||||
ticks: {
|
||||
color: chartColors.value.text,
|
||||
font: {
|
||||
size: 10,
|
||||
size: 10
|
||||
},
|
||||
maxRotation: 45,
|
||||
minRotation: 0,
|
||||
},
|
||||
minRotation: 0
|
||||
}
|
||||
},
|
||||
y: {
|
||||
type: 'linear' as const,
|
||||
display: true,
|
||||
position: 'left' as const,
|
||||
grid: {
|
||||
color: chartColors.value.grid,
|
||||
color: chartColors.value.grid
|
||||
},
|
||||
ticks: {
|
||||
color: '#3b82f6',
|
||||
font: {
|
||||
size: 10,
|
||||
size: 10
|
||||
},
|
||||
callback: (value: string | number) => '$' + formatCost(Number(value)),
|
||||
callback: (value: string | number) => '$' + formatCost(Number(value))
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: t('admin.accounts.stats.cost') + ' (USD)',
|
||||
color: '#3b82f6',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
size: 11
|
||||
}
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
type: 'linear' as const,
|
||||
display: true,
|
||||
position: 'right' as const,
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
drawOnChartArea: false
|
||||
},
|
||||
ticks: {
|
||||
color: '#f97316',
|
||||
font: {
|
||||
size: 10,
|
||||
size: 10
|
||||
},
|
||||
callback: (value: string | number) => formatNumber(Number(value)),
|
||||
callback: (value: string | number) => formatNumber(Number(value))
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: t('admin.accounts.stats.requests'),
|
||||
color: '#f97316',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
size: 11
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
// Load stats when modal opens
|
||||
watch(() => props.show, async (newVal) => {
|
||||
if (newVal && props.account) {
|
||||
await loadStats()
|
||||
} else {
|
||||
stats.value = null
|
||||
watch(
|
||||
() => props.show,
|
||||
async (newVal) => {
|
||||
if (newVal && props.account) {
|
||||
await loadStats()
|
||||
} else {
|
||||
stats.value = null
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const loadStats = async () => {
|
||||
if (!props.account) return
|
||||
|
||||
@@ -1,55 +1,86 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Main Status Badge -->
|
||||
<span
|
||||
:class="[
|
||||
'badge text-xs',
|
||||
statusClass
|
||||
]"
|
||||
>
|
||||
<span :class="['badge text-xs', statusClass]">
|
||||
{{ statusText }}
|
||||
</span>
|
||||
|
||||
<!-- Error Info Indicator -->
|
||||
<div v-if="hasError && account.error_message" class="relative group/error">
|
||||
<svg class="w-4 h-4 text-red-500 dark:text-red-400 cursor-help hover:text-red-600 dark:hover:text-red-300 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
|
||||
<div v-if="hasError && account.error_message" class="group/error relative">
|
||||
<svg
|
||||
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>
|
||||
<!-- Tooltip - 向下显示 -->
|
||||
<div class="absolute top-full left-0 mt-1.5 px-3 py-2 bg-gray-800 dark:bg-gray-900 text-white text-xs rounded-lg shadow-xl opacity-0 invisible group-hover/error:opacity-100 group-hover/error:visible transition-all duration-200 z-[100] min-w-[200px] max-w-[300px]">
|
||||
<div class="text-gray-300 break-words whitespace-pre-wrap leading-relaxed">{{ account.error_message }}</div>
|
||||
<div
|
||||
class="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>
|
||||
|
||||
<!-- Rate Limit Indicator (429) -->
|
||||
<div v-if="isRateLimited" class="relative group">
|
||||
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
<div v-if="isRateLimited" class="group relative">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
429
|
||||
</span>
|
||||
<!-- Tooltip -->
|
||||
<div class="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-50">
|
||||
<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) }}
|
||||
<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>
|
||||
|
||||
<!-- Overload Indicator (529) -->
|
||||
<div v-if="isOverloaded" class="relative group">
|
||||
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400">
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
<div v-if="isOverloaded" class="group relative">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
529
|
||||
</span>
|
||||
<!-- Tooltip -->
|
||||
<div class="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-50">
|
||||
<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) }}
|
||||
<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>
|
||||
|
||||
@@ -7,17 +7,29 @@
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<!-- Account Info Card -->
|
||||
<div v-if="account" class="flex items-center justify-between p-3 bg-gradient-to-r from-gray-50 to-gray-100 dark:from-dark-700 dark:to-dark-600 rounded-xl border border-gray-200 dark:border-dark-500">
|
||||
<div
|
||||
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="w-10 h-10 rounded-lg bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-primary-600"
|
||||
>
|
||||
<svg class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-gray-900 dark:text-gray-100">{{ account.name }}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1.5">
|
||||
<span class="px-1.5 py-0.5 bg-gray-200 dark:bg-dark-500 rounded text-[10px] font-medium uppercase">
|
||||
<div class="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span
|
||||
class="rounded bg-gray-200 px-1.5 py-0.5 text-[10px] font-medium uppercase dark:bg-dark-500"
|
||||
>
|
||||
{{ account.type }}
|
||||
</span>
|
||||
<span>{{ t('admin.accounts.account') }}</span>
|
||||
@@ -26,7 +38,7 @@
|
||||
</div>
|
||||
<span
|
||||
: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'
|
||||
? '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'
|
||||
@@ -44,7 +56,7 @@
|
||||
<select
|
||||
v-model="selectedModelId"
|
||||
: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-for="model in availableModels" :key="model.id" :value="model.id">
|
||||
@@ -54,22 +66,38 @@
|
||||
</div>
|
||||
|
||||
<!-- Terminal Output -->
|
||||
<div class="relative group">
|
||||
<div class="group relative">
|
||||
<div
|
||||
ref="terminalRef"
|
||||
class="bg-gray-900 dark:bg-black rounded-xl p-4 min-h-[120px] max-h-[240px] overflow-y-auto font-mono text-sm border border-gray-700 dark:border-gray-800"
|
||||
class="max-h-[240px] min-h-[120px] overflow-y-auto rounded-xl border border-gray-700 bg-gray-900 p-4 font-mono text-sm dark:border-gray-800 dark:bg-black"
|
||||
>
|
||||
<!-- Status Line -->
|
||||
<div v-if="status === 'idle'" class="text-gray-500 flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
<div v-if="status === 'idle'" class="flex items-center gap-2 text-gray-500">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{{ t('admin.accounts.readyToTest') }}</span>
|
||||
</div>
|
||||
<div v-else-if="status === 'connecting'" class="text-yellow-400 flex items-center gap-2">
|
||||
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
<div v-else-if="status === 'connecting'" class="flex items-center gap-2 text-yellow-400">
|
||||
<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>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<span>{{ t('admin.accounts.connectingToApi') }}</span>
|
||||
</div>
|
||||
@@ -85,15 +113,31 @@
|
||||
</div>
|
||||
|
||||
<!-- Result Status -->
|
||||
<div v-if="status === 'success'" class="text-green-400 mt-3 pt-3 border-t border-gray-700 flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<div
|
||||
v-if="status === 'success'"
|
||||
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>
|
||||
<span>{{ t('admin.accounts.testCompleted') }}</span>
|
||||
</div>
|
||||
<div v-else-if="status === 'error'" class="text-red-400 mt-3 pt-3 border-t border-gray-700 flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<div
|
||||
v-else-if="status === 'error'"
|
||||
class="mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-red-400"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{{ errorMessage }}</span>
|
||||
</div>
|
||||
@@ -103,28 +147,43 @@
|
||||
<button
|
||||
v-if="outputLines.length > 0"
|
||||
@click="copyOutput"
|
||||
class="absolute top-2 right-2 p-1.5 text-gray-400 hover:text-white bg-gray-800/80 hover:bg-gray-700 rounded-lg transition-all opacity-0 group-hover:opacity-100"
|
||||
class="absolute right-2 top-2 rounded-lg bg-gray-800/80 p-1.5 text-gray-400 opacity-0 transition-all hover:bg-gray-700 hover:text-white group-hover:opacity-100"
|
||||
:title="t('admin.accounts.copyOutput')"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Test Info -->
|
||||
<div class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 px-1">
|
||||
<div class="flex items-center justify-between px-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.testModel') }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.testPrompt') }}
|
||||
</span>
|
||||
@@ -135,7 +194,7 @@
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
@click="handleClose"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-dark-600 hover:bg-gray-200 dark:hover:bg-dark-500 rounded-lg transition-colors"
|
||||
class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
|
||||
:disabled="status === 'connecting'"
|
||||
>
|
||||
{{ t('common.close') }}
|
||||
@@ -144,29 +203,72 @@
|
||||
@click="startTest"
|
||||
:disabled="status === 'connecting' || !selectedModelId"
|
||||
: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
|
||||
? 'bg-primary-400 text-white cursor-not-allowed'
|
||||
? 'cursor-not-allowed bg-primary-400 text-white'
|
||||
: status === 'success'
|
||||
? 'bg-green-500 hover:bg-green-600 text-white'
|
||||
? 'bg-green-500 text-white hover:bg-green-600'
|
||||
: status === 'error'
|
||||
? 'bg-orange-500 hover:bg-orange-600 text-white'
|
||||
: 'bg-primary-500 hover:bg-primary-600 text-white'
|
||||
? 'bg-orange-500 text-white hover:bg-orange-600'
|
||||
: '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">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
<svg
|
||||
v-if="status === 'connecting'"
|
||||
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 v-else-if="status === 'idle'" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<svg
|
||||
v-else-if="status === 'idle'"
|
||||
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 v-else class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
<svg 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"
|
||||
/>
|
||||
</svg>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
@@ -208,14 +310,17 @@ const loadingModels = ref(false)
|
||||
let eventSource: EventSource | null = null
|
||||
|
||||
// Load available models when modal opens
|
||||
watch(() => props.show, async (newVal) => {
|
||||
if (newVal && props.account) {
|
||||
resetState()
|
||||
await loadAvailableModels()
|
||||
} else {
|
||||
closeEventSource()
|
||||
watch(
|
||||
() => props.show,
|
||||
async (newVal) => {
|
||||
if (newVal && props.account) {
|
||||
resetState()
|
||||
await loadAvailableModels()
|
||||
} else {
|
||||
closeEventSource()
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const loadAvailableModels = async () => {
|
||||
if (!props.account) return
|
||||
@@ -227,7 +332,7 @@ const loadAvailableModels = async () => {
|
||||
// Default to first model (usually Sonnet)
|
||||
if (availableModels.value.length > 0) {
|
||||
// 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
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -290,7 +395,7 @@ const startTest = async () => {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`,
|
||||
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ model_id: selectedModelId.value })
|
||||
@@ -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) {
|
||||
case 'test_start':
|
||||
addLine(t('admin.accounts.connectedToApi'), 'text-green-400')
|
||||
@@ -382,7 +493,7 @@ const handleEvent = (event: { type: string; text?: string; model?: string; succe
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<div>
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="space-y-0.5">
|
||||
<div class="h-3 w-12 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
||||
<div class="h-3 w-16 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
||||
<div class="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
||||
<div class="h-3 w-12 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="h-3 w-16 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="h-3 w-10 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
@@ -17,24 +17,28 @@
|
||||
<!-- Requests -->
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-gray-500 dark:text-gray-400">Req:</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ formatNumber(stats.requests) }}</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{
|
||||
formatNumber(stats.requests)
|
||||
}}</span>
|
||||
</div>
|
||||
<!-- Tokens -->
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-gray-500 dark:text-gray-400">Tok:</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ formatTokens(stats.tokens) }}</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{
|
||||
formatTokens(stats.tokens)
|
||||
}}</span>
|
||||
</div>
|
||||
<!-- Cost -->
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-gray-500 dark:text-gray-400">Cost:</span>
|
||||
<span class="font-medium text-emerald-600 dark:text-emerald-400">{{ formatCurrency(stats.cost) }}</span>
|
||||
<span class="font-medium text-emerald-600 dark:text-emerald-400">{{
|
||||
formatCurrency(stats.cost)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No data -->
|
||||
<div v-else class="text-xs text-gray-400">
|
||||
-
|
||||
</div>
|
||||
<div v-else class="text-xs text-gray-400">-</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
<template>
|
||||
<div v-if="showUsageWindows">
|
||||
<!-- Anthropic OAuth and Setup Token accounts: fetch real usage data -->
|
||||
<template v-if="account.platform === 'anthropic' && (account.type === 'oauth' || account.type === 'setup-token')">
|
||||
<template
|
||||
v-if="
|
||||
account.platform === 'anthropic' &&
|
||||
(account.type === 'oauth' || account.type === 'setup-token')
|
||||
"
|
||||
>
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="space-y-1.5">
|
||||
<!-- OAuth: 3 rows, Setup Token: 1 row -->
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
||||
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse"></div>
|
||||
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
||||
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="h-1.5 w-8 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
<template v-if="account.type === 'oauth'">
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
||||
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse"></div>
|
||||
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
||||
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="h-1.5 w-8 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
||||
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse"></div>
|
||||
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
||||
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="h-1.5 w-8 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -61,9 +66,7 @@
|
||||
</div>
|
||||
|
||||
<!-- No data yet -->
|
||||
<div v-else class="text-xs text-gray-400">
|
||||
-
|
||||
</div>
|
||||
<div v-else class="text-xs text-gray-400">-</div>
|
||||
</template>
|
||||
|
||||
<!-- OpenAI OAuth accounts: show Codex usage from extra field -->
|
||||
@@ -97,9 +100,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Non-OAuth/Setup-Token accounts -->
|
||||
<div v-else class="text-xs text-gray-400">
|
||||
-
|
||||
</div>
|
||||
<div v-else class="text-xs text-gray-400">-</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -117,20 +118,21 @@ const error = ref<string | null>(null)
|
||||
const usageInfo = ref<AccountUsageInfo | null>(null)
|
||||
|
||||
// Show usage windows for OAuth and Setup Token accounts
|
||||
const showUsageWindows = computed(() =>
|
||||
props.account.type === 'oauth' || props.account.type === 'setup-token'
|
||||
const showUsageWindows = computed(
|
||||
() => props.account.type === 'oauth' || props.account.type === 'setup-token'
|
||||
)
|
||||
|
||||
// OpenAI Codex usage computed properties
|
||||
const hasCodexUsage = computed(() => {
|
||||
const extra = props.account.extra
|
||||
return extra && (
|
||||
return (
|
||||
extra &&
|
||||
// Check for new canonical fields first
|
||||
extra.codex_5h_used_percent !== undefined ||
|
||||
extra.codex_7d_used_percent !== undefined ||
|
||||
// Fallback to legacy fields
|
||||
extra.codex_primary_used_percent !== undefined ||
|
||||
extra.codex_secondary_used_percent !== undefined
|
||||
(extra.codex_5h_used_percent !== undefined ||
|
||||
extra.codex_7d_used_percent !== undefined ||
|
||||
// Fallback to legacy fields
|
||||
extra.codex_primary_used_percent !== undefined ||
|
||||
extra.codex_secondary_used_percent !== undefined)
|
||||
)
|
||||
})
|
||||
|
||||
@@ -145,10 +147,16 @@ const codex5hUsedPercent = computed(() => {
|
||||
}
|
||||
|
||||
// Fallback: detect from legacy fields using window_minutes
|
||||
if (extra.codex_primary_window_minutes !== undefined && extra.codex_primary_window_minutes <= 360) {
|
||||
if (
|
||||
extra.codex_primary_window_minutes !== undefined &&
|
||||
extra.codex_primary_window_minutes <= 360
|
||||
) {
|
||||
return extra.codex_primary_used_percent ?? null
|
||||
}
|
||||
if (extra.codex_secondary_window_minutes !== undefined && extra.codex_secondary_window_minutes <= 360) {
|
||||
if (
|
||||
extra.codex_secondary_window_minutes !== undefined &&
|
||||
extra.codex_secondary_window_minutes <= 360
|
||||
) {
|
||||
return extra.codex_secondary_used_percent ?? null
|
||||
}
|
||||
|
||||
@@ -167,13 +175,19 @@ const codex5hResetAt = computed(() => {
|
||||
}
|
||||
|
||||
// Fallback: detect from legacy fields using window_minutes
|
||||
if (extra.codex_primary_window_minutes !== undefined && extra.codex_primary_window_minutes <= 360) {
|
||||
if (
|
||||
extra.codex_primary_window_minutes !== undefined &&
|
||||
extra.codex_primary_window_minutes <= 360
|
||||
) {
|
||||
if (extra.codex_primary_reset_after_seconds !== undefined) {
|
||||
const resetTime = new Date(Date.now() + extra.codex_primary_reset_after_seconds * 1000)
|
||||
return resetTime.toISOString()
|
||||
}
|
||||
}
|
||||
if (extra.codex_secondary_window_minutes !== undefined && extra.codex_secondary_window_minutes <= 360) {
|
||||
if (
|
||||
extra.codex_secondary_window_minutes !== undefined &&
|
||||
extra.codex_secondary_window_minutes <= 360
|
||||
) {
|
||||
if (extra.codex_secondary_reset_after_seconds !== undefined) {
|
||||
const resetTime = new Date(Date.now() + extra.codex_secondary_reset_after_seconds * 1000)
|
||||
return resetTime.toISOString()
|
||||
@@ -200,10 +214,16 @@ const codex7dUsedPercent = computed(() => {
|
||||
}
|
||||
|
||||
// Fallback: detect from legacy fields using window_minutes
|
||||
if (extra.codex_primary_window_minutes !== undefined && extra.codex_primary_window_minutes >= 10000) {
|
||||
if (
|
||||
extra.codex_primary_window_minutes !== undefined &&
|
||||
extra.codex_primary_window_minutes >= 10000
|
||||
) {
|
||||
return extra.codex_primary_used_percent ?? null
|
||||
}
|
||||
if (extra.codex_secondary_window_minutes !== undefined && extra.codex_secondary_window_minutes >= 10000) {
|
||||
if (
|
||||
extra.codex_secondary_window_minutes !== undefined &&
|
||||
extra.codex_secondary_window_minutes >= 10000
|
||||
) {
|
||||
return extra.codex_secondary_used_percent ?? null
|
||||
}
|
||||
|
||||
@@ -222,13 +242,19 @@ const codex7dResetAt = computed(() => {
|
||||
}
|
||||
|
||||
// Fallback: detect from legacy fields using window_minutes
|
||||
if (extra.codex_primary_window_minutes !== undefined && extra.codex_primary_window_minutes >= 10000) {
|
||||
if (
|
||||
extra.codex_primary_window_minutes !== undefined &&
|
||||
extra.codex_primary_window_minutes >= 10000
|
||||
) {
|
||||
if (extra.codex_primary_reset_after_seconds !== undefined) {
|
||||
const resetTime = new Date(Date.now() + extra.codex_primary_reset_after_seconds * 1000)
|
||||
return resetTime.toISOString()
|
||||
}
|
||||
}
|
||||
if (extra.codex_secondary_window_minutes !== undefined && extra.codex_secondary_window_minutes >= 10000) {
|
||||
if (
|
||||
extra.codex_secondary_window_minutes !== undefined &&
|
||||
extra.codex_secondary_window_minutes >= 10000
|
||||
) {
|
||||
if (extra.codex_secondary_reset_after_seconds !== undefined) {
|
||||
const resetTime = new Date(Date.now() + extra.codex_secondary_reset_after_seconds * 1000)
|
||||
return resetTime.toISOString()
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<Modal :show="show" :title="t('admin.accounts.bulkEdit.title')" size="lg" @close="handleClose">
|
||||
<form class="space-y-5" @submit.prevent="handleSubmit">
|
||||
<!-- Info -->
|
||||
<div class="rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4">
|
||||
<div class="rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20">
|
||||
<p class="text-sm text-blue-700 dark:text-blue-400">
|
||||
<svg class="w-5 h-5 inline mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg class="mr-1.5 inline h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -17,8 +17,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Base URL (API Key only) -->
|
||||
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.baseUrl') }}</label>
|
||||
<input
|
||||
v-model="enableBaseUrl"
|
||||
@@ -31,7 +31,7 @@
|
||||
type="text"
|
||||
:disabled="!enableBaseUrl"
|
||||
class="input"
|
||||
:class="!enableBaseUrl && 'opacity-50 cursor-not-allowed'"
|
||||
:class="!enableBaseUrl && 'cursor-not-allowed opacity-50'"
|
||||
:placeholder="t('admin.accounts.bulkEdit.baseUrlPlaceholder')"
|
||||
/>
|
||||
<p class="input-hint">
|
||||
@@ -40,8 +40,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Model restriction -->
|
||||
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.modelRestriction') }}</label>
|
||||
<input
|
||||
v-model="enableModelRestriction"
|
||||
@@ -50,21 +50,21 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="!enableModelRestriction && 'opacity-50 pointer-events-none'">
|
||||
<div :class="!enableModelRestriction && 'pointer-events-none opacity-50'">
|
||||
<!-- Mode Toggle -->
|
||||
<div class="flex gap-2 mb-4">
|
||||
<div class="mb-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||
modelRestrictionMode === 'whitelist'
|
||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500',
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||
]"
|
||||
@click="modelRestrictionMode = 'whitelist'"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 inline mr-1.5"
|
||||
class="mr-1.5 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -84,12 +84,12 @@
|
||||
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||
modelRestrictionMode === 'mapping'
|
||||
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500',
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||
]"
|
||||
@click="modelRestrictionMode = 'mapping'"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 inline mr-1.5"
|
||||
class="mr-1.5 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -107,10 +107,10 @@
|
||||
|
||||
<!-- Whitelist Mode -->
|
||||
<div v-if="modelRestrictionMode === 'whitelist'">
|
||||
<div class="mb-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-3">
|
||||
<div class="mb-3 rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
|
||||
<p class="text-xs text-blue-700 dark:text-blue-400">
|
||||
<svg
|
||||
class="w-4 h-4 inline mr-1"
|
||||
class="mr-1 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -127,7 +127,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Model Checkbox List -->
|
||||
<div class="grid grid-cols-2 gap-2 mb-3">
|
||||
<div class="mb-3 grid grid-cols-2 gap-2">
|
||||
<label
|
||||
v-for="model in allModels"
|
||||
:key="model.value"
|
||||
@@ -158,10 +158,10 @@
|
||||
|
||||
<!-- Mapping Mode -->
|
||||
<div v-else>
|
||||
<div class="mb-3 rounded-lg bg-purple-50 dark:bg-purple-900/20 p-3">
|
||||
<div class="mb-3 rounded-lg bg-purple-50 p-3 dark:bg-purple-900/20">
|
||||
<p class="text-xs text-purple-700 dark:text-purple-400">
|
||||
<svg
|
||||
class="w-4 h-4 inline mr-1"
|
||||
class="mr-1 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -178,7 +178,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Model Mapping List -->
|
||||
<div v-if="modelMappings.length > 0" class="space-y-2 mb-3">
|
||||
<div v-if="modelMappings.length > 0" class="mb-3 space-y-2">
|
||||
<div
|
||||
v-for="(mapping, index) in modelMappings"
|
||||
:key="index"
|
||||
@@ -191,7 +191,7 @@
|
||||
:placeholder="t('admin.accounts.requestModel')"
|
||||
/>
|
||||
<svg
|
||||
class="w-4 h-4 text-gray-400 flex-shrink-0"
|
||||
class="h-4 w-4 flex-shrink-0 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -211,10 +211,10 @@
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
||||
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
@click="removeModelMapping(index)"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -228,11 +228,11 @@
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-lg border-2 border-dashed border-gray-300 dark:border-dark-500 px-4 py-2 text-gray-600 dark:text-gray-400 transition-colors hover:border-gray-400 hover:text-gray-700 dark:hover:border-dark-400 dark:hover:text-gray-300 mb-3"
|
||||
class="mb-3 w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
|
||||
@click="addModelMapping"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 inline mr-1"
|
||||
class="mr-1 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -264,11 +264,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Custom error codes -->
|
||||
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.customErrorCodes') }}</label>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.customErrorCodesHint') }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -280,10 +280,10 @@
|
||||
</div>
|
||||
|
||||
<div v-if="enableCustomErrorCodes" class="space-y-3">
|
||||
<div class="rounded-lg bg-amber-50 dark:bg-amber-900/20 p-3">
|
||||
<div class="rounded-lg bg-amber-50 p-3 dark:bg-amber-900/20">
|
||||
<p class="text-xs text-amber-700 dark:text-amber-400">
|
||||
<svg
|
||||
class="w-4 h-4 inline mr-1"
|
||||
class="mr-1 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -308,8 +308,8 @@
|
||||
:class="[
|
||||
'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
|
||||
selectedErrorCodes.includes(code.value)
|
||||
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 ring-1 ring-red-500'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500',
|
||||
? 'bg-red-100 text-red-700 ring-1 ring-red-500 dark:bg-red-900/30 dark:text-red-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||
]"
|
||||
@click="toggleErrorCode(code.value)"
|
||||
>
|
||||
@@ -329,7 +329,7 @@
|
||||
@keyup.enter="addCustomErrorCode"
|
||||
/>
|
||||
<button type="button" class="btn btn-secondary px-3" @click="addCustomErrorCode">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -345,7 +345,7 @@
|
||||
<span
|
||||
v-for="code in selectedErrorCodes.sort((a, b) => a - b)"
|
||||
:key="code"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-red-100 dark:bg-red-900/30 px-2.5 py-0.5 text-sm font-medium text-red-700 dark:text-red-400"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-red-100 px-2.5 py-0.5 text-sm font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||||
>
|
||||
{{ code }}
|
||||
<button
|
||||
@@ -353,7 +353,7 @@
|
||||
class="hover:text-red-900 dark:hover:text-red-300"
|
||||
@click="removeErrorCode(code)"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -371,13 +371,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Intercept warmup requests (Anthropic only) -->
|
||||
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 pr-4">
|
||||
<label class="input-label mb-0">{{
|
||||
t('admin.accounts.interceptWarmupRequests')
|
||||
}}</label>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.interceptWarmupRequestsDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -392,14 +392,14 @@
|
||||
type="button"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
interceptWarmupRequests ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600',
|
||||
interceptWarmupRequests ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
@click="interceptWarmupRequests = !interceptWarmupRequests"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
interceptWarmupRequests ? 'translate-x-5' : 'translate-x-0',
|
||||
interceptWarmupRequests ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
@@ -407,8 +407,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Proxy -->
|
||||
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.proxy') }}</label>
|
||||
<input
|
||||
v-model="enableProxy"
|
||||
@@ -416,15 +416,15 @@
|
||||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div :class="!enableProxy && 'opacity-50 pointer-events-none'">
|
||||
<div :class="!enableProxy && 'pointer-events-none opacity-50'">
|
||||
<ProxySelector v-model="proxyId" :proxies="proxies" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Concurrency & Priority -->
|
||||
<div class="grid grid-cols-2 gap-4 border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||
<div class="grid grid-cols-2 gap-4 border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.concurrency') }}</label>
|
||||
<input
|
||||
v-model="enableConcurrency"
|
||||
@@ -438,11 +438,11 @@
|
||||
min="1"
|
||||
:disabled="!enableConcurrency"
|
||||
class="input"
|
||||
:class="!enableConcurrency && 'opacity-50 cursor-not-allowed'"
|
||||
:class="!enableConcurrency && 'cursor-not-allowed opacity-50'"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.priority') }}</label>
|
||||
<input
|
||||
v-model="enablePriority"
|
||||
@@ -456,14 +456,14 @@
|
||||
min="1"
|
||||
:disabled="!enablePriority"
|
||||
class="input"
|
||||
:class="!enablePriority && 'opacity-50 cursor-not-allowed'"
|
||||
:class="!enablePriority && 'cursor-not-allowed opacity-50'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<label class="input-label mb-0">{{ t('common.status') }}</label>
|
||||
<input
|
||||
v-model="enableStatus"
|
||||
@@ -471,14 +471,14 @@
|
||||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div :class="!enableStatus && 'opacity-50 pointer-events-none'">
|
||||
<div :class="!enableStatus && 'pointer-events-none opacity-50'">
|
||||
<Select v-model="status" :options="statusOptions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Groups -->
|
||||
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<label class="input-label mb-0">{{ t('nav.groups') }}</label>
|
||||
<input
|
||||
v-model="enableGroups"
|
||||
@@ -486,7 +486,7 @@
|
||||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div :class="!enableGroups && 'opacity-50 pointer-events-none'">
|
||||
<div :class="!enableGroups && 'pointer-events-none opacity-50'">
|
||||
<GroupSelector v-model="groupIds" :groups="groups" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -499,7 +499,7 @@
|
||||
<button type="submit" :disabled="submitting" class="btn btn-primary">
|
||||
<svg
|
||||
v-if="submitting"
|
||||
class="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||
class="-ml-1 mr-2 h-4 w-4 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
@@ -601,7 +601,7 @@ const allModels = [
|
||||
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
|
||||
{ value: 'gpt-5.1-2025-11-13', label: 'GPT-5.1' },
|
||||
{ value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' },
|
||||
{ value: 'gpt-5-2025-08-07', label: 'GPT-5' },
|
||||
{ value: 'gpt-5-2025-08-07', label: 'GPT-5' }
|
||||
]
|
||||
|
||||
// Preset mappings (combined Anthropic + OpenAI)
|
||||
@@ -610,48 +610,46 @@ const presetMappings = [
|
||||
label: 'Sonnet 4',
|
||||
from: 'claude-sonnet-4-20250514',
|
||||
to: 'claude-sonnet-4-20250514',
|
||||
color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
},
|
||||
{
|
||||
label: 'Sonnet 4.5',
|
||||
from: 'claude-sonnet-4-5-20250929',
|
||||
to: 'claude-sonnet-4-5-20250929',
|
||||
color:
|
||||
'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400',
|
||||
'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400'
|
||||
},
|
||||
{
|
||||
label: 'Opus 4.5',
|
||||
from: 'claude-opus-4-5-20251101',
|
||||
to: 'claude-opus-4-5-20251101',
|
||||
color:
|
||||
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400',
|
||||
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
},
|
||||
{
|
||||
label: 'Opus->Sonnet',
|
||||
from: 'claude-opus-4-5-20251101',
|
||||
to: 'claude-sonnet-4-5-20250929',
|
||||
color:
|
||||
'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400',
|
||||
color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400'
|
||||
},
|
||||
{
|
||||
label: 'GPT-5.2',
|
||||
from: 'gpt-5.2-2025-12-11',
|
||||
to: 'gpt-5.2-2025-12-11',
|
||||
color:
|
||||
'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400',
|
||||
color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400'
|
||||
},
|
||||
{
|
||||
label: 'GPT-5.2 Codex',
|
||||
from: 'gpt-5.2-codex',
|
||||
to: 'gpt-5.2-codex',
|
||||
color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
},
|
||||
{
|
||||
label: 'Max->Codex',
|
||||
from: 'gpt-5.1-codex-max',
|
||||
to: 'gpt-5.1-codex',
|
||||
color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400',
|
||||
},
|
||||
color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400'
|
||||
}
|
||||
]
|
||||
|
||||
// Common HTTP error codes
|
||||
@@ -662,12 +660,12 @@ const commonErrorCodes = [
|
||||
{ value: 500, label: 'Server Error' },
|
||||
{ value: 502, label: 'Bad Gateway' },
|
||||
{ value: 503, label: 'Unavailable' },
|
||||
{ value: 529, label: 'Overloaded' },
|
||||
{ value: 529, label: 'Overloaded' }
|
||||
]
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{ value: 'active', label: t('common.active') },
|
||||
{ value: 'inactive', label: t('common.inactive') },
|
||||
{ value: 'inactive', label: t('common.inactive') }
|
||||
])
|
||||
|
||||
// Model mapping helpers
|
||||
|
||||
@@ -10,10 +10,15 @@
|
||||
<div class="text-sm text-gray-600 dark:text-dark-300">
|
||||
{{ t('admin.accounts.syncFromCrsDesc') }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-dark-400 bg-gray-50 dark:bg-dark-700/60 rounded-lg p-3">
|
||||
已有账号仅同步 CRS 返回的字段,缺失字段保持原值;凭据按键合并,不会清空未下发的键;未勾选"同步代理"时保留原有代理。
|
||||
<div
|
||||
class="rounded-lg bg-gray-50 p-3 text-xs text-gray-500 dark:bg-dark-700/60 dark:text-dark-400"
|
||||
>
|
||||
已有账号仅同步 CRS
|
||||
返回的字段,缺失字段保持原值;凭据按键合并,不会清空未下发的键;未勾选"同步代理"时保留原有代理。
|
||||
</div>
|
||||
<div class="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
|
||||
<div
|
||||
class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-600 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-400"
|
||||
>
|
||||
{{ t('admin.accounts.crsVersionRequirement') }}
|
||||
</div>
|
||||
|
||||
@@ -31,12 +36,7 @@
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.crsUsername') }}</label>
|
||||
<input
|
||||
v-model="form.username"
|
||||
type="text"
|
||||
class="input"
|
||||
autocomplete="username"
|
||||
/>
|
||||
<input v-model="form.username" type="text" class="input" autocomplete="username" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.crsPassword') }}</label>
|
||||
@@ -50,12 +50,19 @@
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-dark-300">
|
||||
<input v-model="form.sync_proxies" type="checkbox" class="rounded border-gray-300 dark:border-dark-600" />
|
||||
<input
|
||||
v-model="form.sync_proxies"
|
||||
type="checkbox"
|
||||
class="rounded border-gray-300 dark:border-dark-600"
|
||||
/>
|
||||
{{ t('admin.accounts.syncProxies') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="result" class="rounded-xl border border-gray-200 dark:border-dark-700 p-4 space-y-2">
|
||||
<div
|
||||
v-if="result"
|
||||
class="space-y-2 rounded-xl border border-gray-200 p-4 dark:border-dark-700"
|
||||
>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.accounts.syncResult') }}
|
||||
</div>
|
||||
@@ -67,9 +74,12 @@
|
||||
<div class="text-sm font-medium text-red-600 dark:text-red-400">
|
||||
{{ t('admin.accounts.syncErrors') }}
|
||||
</div>
|
||||
<div class="mt-2 max-h-48 overflow-auto rounded-lg bg-gray-50 dark:bg-dark-800 p-3 text-xs font-mono">
|
||||
<div
|
||||
class="mt-2 max-h-48 overflow-auto rounded-lg bg-gray-50 p-3 font-mono text-xs dark:bg-dark-800"
|
||||
>
|
||||
<div v-for="(item, idx) in errorItems" :key="idx" class="whitespace-pre-wrap">
|
||||
{{ item.kind }} {{ item.crs_account_id }} — {{ item.action }}{{ item.error ? `: ${item.error}` : '' }}
|
||||
{{ item.kind }} {{ item.crs_account_id }} — {{ item.action
|
||||
}}{{ item.error ? `: ${item.error}` : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Window stats row (above progress bar, left-right aligned with progress bar) -->
|
||||
<div v-if="windowStats" class="flex items-center justify-between mb-0.5" :title="`5h 窗口用量统计`">
|
||||
<div class="flex items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400 cursor-help">
|
||||
<span class="px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800">
|
||||
<div
|
||||
v-if="windowStats"
|
||||
class="mb-0.5 flex items-center justify-between"
|
||||
:title="`5h 窗口用量统计`"
|
||||
>
|
||||
<div
|
||||
class="flex cursor-help items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
|
||||
{{ formatRequests }} req
|
||||
</span>
|
||||
<span class="px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800">
|
||||
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
|
||||
{{ formatTokens }}
|
||||
</span>
|
||||
<span class="px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800">
|
||||
${{ formatCost }}
|
||||
</span>
|
||||
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"> ${{ formatCost }} </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,16 +23,13 @@
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Label badge (fixed width for alignment) -->
|
||||
<span
|
||||
:class="[
|
||||
'text-[10px] font-medium px-1 rounded w-[32px] text-center shrink-0',
|
||||
labelClass
|
||||
]"
|
||||
:class="['w-[32px] shrink-0 rounded px-1 text-center text-[10px] font-medium', labelClass]"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
|
||||
<!-- Progress bar container -->
|
||||
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden shrink-0">
|
||||
<div class="h-1.5 w-8 shrink-0 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
:class="['h-full transition-all duration-300', barClass]"
|
||||
:style="{ width: barWidth }"
|
||||
@@ -36,12 +37,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Percentage -->
|
||||
<span :class="['text-[10px] font-medium w-[32px] text-right shrink-0', textClass]">
|
||||
<span :class="['w-[32px] shrink-0 text-right text-[10px] font-medium', textClass]">
|
||||
{{ displayPercent }}
|
||||
</span>
|
||||
|
||||
<!-- Reset time -->
|
||||
<span v-if="resetsAt" class="text-[10px] text-gray-400 shrink-0">
|
||||
<span v-if="resetsAt" class="shrink-0 text-[10px] text-gray-400">
|
||||
{{ formatResetTime }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -54,7 +55,7 @@ import type { WindowStats } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
utilization: number // Percentage (0-100+)
|
||||
utilization: number // Percentage (0-100+)
|
||||
resetsAt?: string | null
|
||||
color: 'indigo' | 'emerald' | 'purple'
|
||||
windowStats?: WindowStats | null
|
||||
|
||||
@@ -1,37 +1,59 @@
|
||||
<template>
|
||||
<div class="card p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">{{ t('admin.dashboard.modelDistribution') }}</h3>
|
||||
<div v-if="loading" class="flex items-center justify-center h-48">
|
||||
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.dashboard.modelDistribution') }}
|
||||
</h3>
|
||||
<div v-if="loading" class="flex h-48 items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
<div v-else-if="modelStats.length > 0 && chartData" class="flex items-center gap-6">
|
||||
<div class="w-48 h-48">
|
||||
<div class="h-48 w-48">
|
||||
<Doughnut :data="chartData" :options="doughnutOptions" />
|
||||
</div>
|
||||
<div class="flex-1 max-h-48 overflow-y-auto">
|
||||
<div class="max-h-48 flex-1 overflow-y-auto">
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="text-gray-500 dark:text-gray-400">
|
||||
<th class="text-left pb-2">{{ t('admin.dashboard.model') }}</th>
|
||||
<th class="text-right pb-2">{{ t('admin.dashboard.requests') }}</th>
|
||||
<th class="text-right pb-2">{{ t('admin.dashboard.tokens') }}</th>
|
||||
<th class="text-right pb-2">{{ t('admin.dashboard.actual') }}</th>
|
||||
<th class="text-right pb-2">{{ t('admin.dashboard.standard') }}</th>
|
||||
<th class="pb-2 text-left">{{ t('admin.dashboard.model') }}</th>
|
||||
<th class="pb-2 text-right">{{ t('admin.dashboard.requests') }}</th>
|
||||
<th class="pb-2 text-right">{{ t('admin.dashboard.tokens') }}</th>
|
||||
<th class="pb-2 text-right">{{ t('admin.dashboard.actual') }}</th>
|
||||
<th class="pb-2 text-right">{{ t('admin.dashboard.standard') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="model in modelStats" :key="model.model" class="border-t border-gray-100 dark:border-gray-700">
|
||||
<td class="py-1.5 text-gray-900 dark:text-white font-medium truncate max-w-[100px]" :title="model.model">{{ model.model }}</td>
|
||||
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">{{ formatNumber(model.requests) }}</td>
|
||||
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">{{ formatTokens(model.total_tokens) }}</td>
|
||||
<td class="py-1.5 text-right text-green-600 dark:text-green-400">${{ formatCost(model.actual_cost) }}</td>
|
||||
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">${{ formatCost(model.cost) }}</td>
|
||||
<tr
|
||||
v-for="model in modelStats"
|
||||
:key="model.model"
|
||||
class="border-t border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<td
|
||||
class="max-w-[100px] truncate py-1.5 font-medium text-gray-900 dark:text-white"
|
||||
:title="model.model"
|
||||
>
|
||||
{{ model.model }}
|
||||
</td>
|
||||
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
|
||||
{{ formatNumber(model.requests) }}
|
||||
</td>
|
||||
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
|
||||
{{ formatTokens(model.total_tokens) }}
|
||||
</td>
|
||||
<td class="py-1.5 text-right text-green-600 dark:text-green-400">
|
||||
${{ formatCost(model.actual_cost) }}
|
||||
</td>
|
||||
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">
|
||||
${{ formatCost(model.cost) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex items-center justify-center h-48 text-gray-500 dark:text-gray-400 text-sm">
|
||||
<div
|
||||
v-else
|
||||
class="flex h-48 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ t('admin.dashboard.noDataAvailable') }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,12 +62,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
ArcElement,
|
||||
Tooltip,
|
||||
Legend
|
||||
} from 'chart.js'
|
||||
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js'
|
||||
import { Doughnut } from 'vue-chartjs'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import type { ModelStat } from '@/types'
|
||||
@@ -60,20 +77,30 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const chartColors = [
|
||||
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
|
||||
'#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16'
|
||||
'#3b82f6',
|
||||
'#10b981',
|
||||
'#f59e0b',
|
||||
'#ef4444',
|
||||
'#8b5cf6',
|
||||
'#ec4899',
|
||||
'#14b8a6',
|
||||
'#f97316',
|
||||
'#6366f1',
|
||||
'#84cc16'
|
||||
]
|
||||
|
||||
const chartData = computed(() => {
|
||||
if (!props.modelStats?.length) return null
|
||||
|
||||
return {
|
||||
labels: props.modelStats.map(m => m.model),
|
||||
datasets: [{
|
||||
data: props.modelStats.map(m => m.total_tokens),
|
||||
backgroundColor: chartColors.slice(0, props.modelStats.length),
|
||||
borderWidth: 0,
|
||||
}],
|
||||
labels: props.modelStats.map((m) => m.model),
|
||||
datasets: [
|
||||
{
|
||||
data: props.modelStats.map((m) => m.total_tokens),
|
||||
backgroundColor: chartColors.slice(0, props.modelStats.length),
|
||||
borderWidth: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
@@ -82,7 +109,7 @@ const doughnutOptions = computed(() => ({
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
@@ -91,10 +118,10 @@ const doughnutOptions = computed(() => ({
|
||||
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
|
||||
const percentage = ((value / total) * 100).toFixed(1)
|
||||
return `${context.label}: ${formatTokens(value)} (${percentage}%)`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const formatTokens = (value: number): string => {
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
<template>
|
||||
<div class="card p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">{{ t('admin.dashboard.tokenUsageTrend') }}</h3>
|
||||
<div v-if="loading" class="flex items-center justify-center h-48">
|
||||
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.dashboard.tokenUsageTrend') }}
|
||||
</h3>
|
||||
<div v-if="loading" class="flex h-48 items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
<div v-else-if="trendData.length > 0 && chartData" class="h-48">
|
||||
<Line :data="chartData" :options="lineOptions" />
|
||||
</div>
|
||||
<div v-else class="flex items-center justify-center h-48 text-gray-500 dark:text-gray-400 text-sm">
|
||||
<div
|
||||
v-else
|
||||
class="flex h-48 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ t('admin.dashboard.noDataAvailable') }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,40 +63,40 @@ const chartColors = computed(() => ({
|
||||
grid: isDarkMode.value ? '#374151' : '#e5e7eb',
|
||||
input: '#3b82f6',
|
||||
output: '#10b981',
|
||||
cache: '#f59e0b',
|
||||
cache: '#f59e0b'
|
||||
}))
|
||||
|
||||
const chartData = computed(() => {
|
||||
if (!props.trendData?.length) return null
|
||||
|
||||
return {
|
||||
labels: props.trendData.map(d => d.date),
|
||||
labels: props.trendData.map((d) => d.date),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Input',
|
||||
data: props.trendData.map(d => d.input_tokens),
|
||||
data: props.trendData.map((d) => d.input_tokens),
|
||||
borderColor: chartColors.value.input,
|
||||
backgroundColor: `${chartColors.value.input}20`,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
tension: 0.3
|
||||
},
|
||||
{
|
||||
label: 'Output',
|
||||
data: props.trendData.map(d => d.output_tokens),
|
||||
data: props.trendData.map((d) => d.output_tokens),
|
||||
borderColor: chartColors.value.output,
|
||||
backgroundColor: `${chartColors.value.output}20`,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
tension: 0.3
|
||||
},
|
||||
{
|
||||
label: 'Cache',
|
||||
data: props.trendData.map(d => d.cache_tokens),
|
||||
data: props.trendData.map((d) => d.cache_tokens),
|
||||
borderColor: chartColors.value.cache,
|
||||
backgroundColor: `${chartColors.value.cache}20`,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
},
|
||||
],
|
||||
tension: 0.3
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
@@ -100,7 +105,7 @@ const lineOptions = computed(() => ({
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index' as const,
|
||||
mode: 'index' as const
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
@@ -111,9 +116,9 @@ const lineOptions = computed(() => ({
|
||||
pointStyle: 'circle',
|
||||
padding: 15,
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
size: 11
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
@@ -127,35 +132,35 @@ const lineOptions = computed(() => ({
|
||||
return `Actual: $${formatCost(data.actual_cost)} | Standard: $${formatCost(data.cost)}`
|
||||
}
|
||||
return ''
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: chartColors.value.grid,
|
||||
color: chartColors.value.grid
|
||||
},
|
||||
ticks: {
|
||||
color: chartColors.value.text,
|
||||
font: {
|
||||
size: 10,
|
||||
},
|
||||
},
|
||||
size: 10
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: chartColors.value.grid,
|
||||
color: chartColors.value.grid
|
||||
},
|
||||
ticks: {
|
||||
color: chartColors.value.text,
|
||||
font: {
|
||||
size: 10,
|
||||
size: 10
|
||||
},
|
||||
callback: (value: string | number) => formatTokens(Number(value)),
|
||||
},
|
||||
},
|
||||
},
|
||||
callback: (value: string | number) => formatTokens(Number(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const formatTokens = (value: number): string => {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<button
|
||||
@click="handleCancel"
|
||||
type="button"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-dark-700 border border-gray-300 dark:border-dark-600 rounded-md hover:bg-gray-50 dark:hover:bg-dark-600 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-dark-800 focus:ring-primary-500"
|
||||
class="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-200 dark:hover:bg-dark-600 dark:focus:ring-offset-dark-800"
|
||||
>
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
@@ -17,7 +17,7 @@
|
||||
@click="handleConfirm"
|
||||
type="button"
|
||||
:class="[
|
||||
'px-4 py-2 text-sm font-medium text-white rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-dark-800',
|
||||
'rounded-md px-4 py-2 text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-dark-800',
|
||||
danger
|
||||
? 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
|
||||
: 'bg-primary-600 hover:bg-primary-700 focus:ring-primary-500'
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
scope="col"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-dark-400 uppercase tracking-wider"
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400"
|
||||
:class="{ 'cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-700': column.sortable }"
|
||||
@click="column.sortable && handleSort(column.key)"
|
||||
>
|
||||
@@ -16,8 +16,8 @@
|
||||
<span v-if="column.sortable" class="text-gray-400 dark:text-dark-500">
|
||||
<svg
|
||||
v-if="sortKey === column.key"
|
||||
class="w-4 h-4"
|
||||
:class="{ 'transform rotate-180': sortOrder === 'desc' }"
|
||||
class="h-4 w-4"
|
||||
:class="{ 'rotate-180 transform': sortOrder === 'desc' }"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
@@ -27,7 +27,7 @@
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<svg v-else class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||||
/>
|
||||
@@ -37,22 +37,30 @@
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-dark-900 divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900">
|
||||
<!-- Loading skeleton -->
|
||||
<tr v-if="loading" v-for="i in 5" :key="i">
|
||||
<td v-for="column in columns" :key="column.key" class="px-6 py-4 whitespace-nowrap">
|
||||
<td v-for="column in columns" :key="column.key" class="whitespace-nowrap px-6 py-4">
|
||||
<div class="animate-pulse">
|
||||
<div class="h-4 bg-gray-200 dark:bg-dark-700 rounded w-3/4"></div>
|
||||
<div class="h-4 w-3/4 rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Empty state -->
|
||||
<tr v-else-if="!data || data.length === 0">
|
||||
<td :colspan="columns.length" class="px-6 py-12 text-center text-gray-500 dark:text-dark-400">
|
||||
<td
|
||||
:colspan="columns.length"
|
||||
class="px-6 py-12 text-center text-gray-500 dark:text-dark-400"
|
||||
>
|
||||
<slot name="empty">
|
||||
<div class="flex flex-col items-center">
|
||||
<svg class="w-12 h-12 text-gray-400 dark:text-dark-500 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg
|
||||
class="mb-4 h-12 w-12 text-gray-400 dark:text-dark-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -60,18 +68,25 @@
|
||||
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-lg font-medium text-gray-900 dark:text-gray-100">{{ t('empty.noData') }}</p>
|
||||
<p class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ t('empty.noData') }}
|
||||
</p>
|
||||
</div>
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Data rows -->
|
||||
<tr v-else v-for="(row, index) in sortedData" :key="index" class="hover:bg-gray-50 dark:hover:bg-dark-800">
|
||||
<tr
|
||||
v-else
|
||||
v-for="(row, index) in sortedData"
|
||||
:key="index"
|
||||
class="hover:bg-gray-50 dark:hover:bg-dark-800"
|
||||
>
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100"
|
||||
class="whitespace-nowrap px-6 py-4 text-sm text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]">
|
||||
{{ column.formatter ? column.formatter(row[column.key], row) : row[column.key] }}
|
||||
|
||||
@@ -3,14 +3,21 @@
|
||||
<button
|
||||
type="button"
|
||||
@click="toggle"
|
||||
:class="[
|
||||
'date-picker-trigger',
|
||||
isOpen && 'date-picker-trigger-open'
|
||||
]"
|
||||
:class="['date-picker-trigger', isOpen && 'date-picker-trigger-open']"
|
||||
>
|
||||
<span class="date-picker-icon">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="date-picker-value">
|
||||
@@ -18,7 +25,7 @@
|
||||
</span>
|
||||
<span class="date-picker-chevron">
|
||||
<svg
|
||||
:class="['w-4 h-4 transition-transform duration-200', isOpen && 'rotate-180']"
|
||||
:class="['h-4 w-4 transition-transform duration-200', isOpen && 'rotate-180']"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -30,20 +37,14 @@
|
||||
</button>
|
||||
|
||||
<Transition name="date-picker-dropdown">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="date-picker-dropdown"
|
||||
>
|
||||
<div v-if="isOpen" class="date-picker-dropdown">
|
||||
<!-- Quick presets -->
|
||||
<div class="date-picker-presets">
|
||||
<button
|
||||
v-for="preset in presets"
|
||||
:key="preset.value"
|
||||
@click="selectPreset(preset)"
|
||||
:class="[
|
||||
'date-picker-preset',
|
||||
isPresetActive(preset) && 'date-picker-preset-active'
|
||||
]"
|
||||
:class="['date-picker-preset', isPresetActive(preset) && 'date-picker-preset-active']"
|
||||
>
|
||||
{{ t(preset.labelKey) }}
|
||||
</button>
|
||||
@@ -64,8 +65,18 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="date-picker-separator">
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 8.25L21 12m0 0l-3.75 3.75M21 12H3" />
|
||||
<svg
|
||||
class="h-4 w-4 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M17.25 8.25L21 12m0 0l-3.75 3.75M21 12H3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="date-picker-field">
|
||||
@@ -83,10 +94,7 @@
|
||||
|
||||
<!-- Apply button -->
|
||||
<div class="date-picker-actions">
|
||||
<button
|
||||
@click="apply"
|
||||
class="date-picker-apply"
|
||||
>
|
||||
<button @click="apply" class="date-picker-apply">
|
||||
{{ t('dates.apply') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -204,7 +212,7 @@ const presets: DatePreset[] = [
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (activePreset.value) {
|
||||
const preset = presets.find(p => p.value === activePreset.value)
|
||||
const preset = presets.find((p) => p.value === activePreset.value)
|
||||
if (preset) return t(preset.labelKey)
|
||||
}
|
||||
|
||||
@@ -275,15 +283,21 @@ const handleEscape = (event: KeyboardEvent) => {
|
||||
}
|
||||
|
||||
// Sync local state with props
|
||||
watch(() => props.startDate, (val) => {
|
||||
localStartDate.value = val
|
||||
onDateChange()
|
||||
})
|
||||
watch(
|
||||
() => props.startDate,
|
||||
(val) => {
|
||||
localStartDate.value = val
|
||||
onDateChange()
|
||||
}
|
||||
)
|
||||
|
||||
watch(() => props.endDate, (val) => {
|
||||
localEndDate.value = val
|
||||
onDateChange()
|
||||
})
|
||||
watch(
|
||||
() => props.endDate,
|
||||
(val) => {
|
||||
localEndDate.value = val
|
||||
onDateChange()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
@@ -301,18 +315,18 @@ onUnmounted(() => {
|
||||
<style scoped>
|
||||
.date-picker-trigger {
|
||||
@apply flex items-center gap-2;
|
||||
@apply px-3 py-2 rounded-lg text-sm;
|
||||
@apply rounded-lg px-3 py-2 text-sm;
|
||||
@apply bg-white dark:bg-dark-800;
|
||||
@apply border border-gray-200 dark:border-dark-600;
|
||||
@apply text-gray-700 dark:text-gray-300;
|
||||
@apply transition-all duration-200;
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-primary-500/30 focus:border-primary-500;
|
||||
@apply focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30;
|
||||
@apply hover:border-gray-300 dark:hover:border-dark-500;
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
.date-picker-trigger-open {
|
||||
@apply ring-2 ring-primary-500/30 border-primary-500;
|
||||
@apply border-primary-500 ring-2 ring-primary-500/30;
|
||||
}
|
||||
|
||||
.date-picker-icon {
|
||||
@@ -328,7 +342,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.date-picker-dropdown {
|
||||
@apply absolute z-[100] mt-2 left-0;
|
||||
@apply absolute left-0 z-[100] mt-2;
|
||||
@apply bg-white dark:bg-dark-800;
|
||||
@apply rounded-xl;
|
||||
@apply border border-gray-200 dark:border-dark-700;
|
||||
@@ -342,7 +356,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.date-picker-preset {
|
||||
@apply px-3 py-1.5 text-xs font-medium rounded-md;
|
||||
@apply rounded-md px-3 py-1.5 text-xs font-medium;
|
||||
@apply text-gray-600 dark:text-gray-400;
|
||||
@apply hover:bg-gray-100 dark:hover:bg-dark-700;
|
||||
@apply transition-colors duration-150;
|
||||
@@ -366,15 +380,15 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.date-picker-label {
|
||||
@apply block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1;
|
||||
@apply mb-1 block text-xs font-medium text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.date-picker-input {
|
||||
@apply w-full px-2 py-1.5 text-sm rounded-md;
|
||||
@apply w-full rounded-md px-2 py-1.5 text-sm;
|
||||
@apply bg-gray-50 dark:bg-dark-700;
|
||||
@apply border border-gray-200 dark:border-dark-600;
|
||||
@apply text-gray-900 dark:text-gray-100;
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-primary-500/30 focus:border-primary-500;
|
||||
@apply focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30;
|
||||
}
|
||||
|
||||
.date-picker-input::-webkit-calendar-picker-indicator {
|
||||
@@ -395,7 +409,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.date-picker-apply {
|
||||
@apply px-4 py-1.5 text-sm font-medium rounded-lg;
|
||||
@apply rounded-lg px-4 py-1.5 text-sm font-medium;
|
||||
@apply bg-primary-600 text-white;
|
||||
@apply hover:bg-primary-700;
|
||||
@apply transition-colors duration-150;
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
<template>
|
||||
<div class="empty-state">
|
||||
<!-- Icon -->
|
||||
<div class="w-20 h-20 mb-5 rounded-2xl bg-gray-100 dark:bg-dark-800 flex items-center justify-center">
|
||||
<div
|
||||
class="mb-5 flex h-20 w-20 items-center justify-center rounded-2xl bg-gray-100 dark:bg-dark-800"
|
||||
>
|
||||
<slot name="icon">
|
||||
<component
|
||||
v-if="icon"
|
||||
:is="icon"
|
||||
class="empty-state-icon w-10 h-10"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<component v-if="icon" :is="icon" class="empty-state-icon h-10 w-10" aria-hidden="true" />
|
||||
<svg
|
||||
v-else
|
||||
class="empty-state-icon w-10 h-10"
|
||||
class="empty-state-icon h-10 w-10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -48,17 +45,13 @@
|
||||
>
|
||||
<svg
|
||||
v-if="actionIcon"
|
||||
class="w-5 h-5 mr-2"
|
||||
class="mr-2 h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 4.5v15m7.5-7.5h-15"
|
||||
/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
{{ actionText }}
|
||||
</component>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md text-xs font-medium transition-colors',
|
||||
'inline-flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium transition-colors',
|
||||
badgeClass
|
||||
]"
|
||||
>
|
||||
@@ -10,10 +10,7 @@
|
||||
<!-- Group name -->
|
||||
<span class="truncate">{{ name }}</span>
|
||||
<!-- Right side label -->
|
||||
<span
|
||||
v-if="showLabel"
|
||||
:class="labelClass"
|
||||
>
|
||||
<span v-if="showLabel" :class="labelClass">
|
||||
{{ labelText }}
|
||||
</span>
|
||||
</span>
|
||||
@@ -31,7 +28,7 @@ interface Props {
|
||||
subscriptionType?: SubscriptionType
|
||||
rateMultiplier?: number
|
||||
showRate?: boolean
|
||||
daysRemaining?: number | null // 剩余天数(订阅类型时使用)
|
||||
daysRemaining?: number | null // 剩余天数(订阅类型时使用)
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -97,6 +94,9 @@ const labelClass = computed(() => {
|
||||
if (props.platform === 'openai') {
|
||||
return `${base} bg-emerald-200/60 text-emerald-800 dark:bg-emerald-800/40 dark:text-emerald-300`
|
||||
}
|
||||
if (props.platform === 'gemini') {
|
||||
return `${base} bg-blue-200/60 text-blue-800 dark:bg-blue-800/40 dark:text-blue-300`
|
||||
}
|
||||
return `${base} bg-violet-200/60 text-violet-800 dark:bg-violet-800/40 dark:text-violet-300`
|
||||
})
|
||||
|
||||
@@ -113,6 +113,11 @@ const badgeClass = computed(() => {
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||
: 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400'
|
||||
}
|
||||
if (props.platform === 'gemini') {
|
||||
return isSubscription.value
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'bg-sky-50 text-sky-700 dark:bg-sky-900/20 dark:text-sky-400'
|
||||
}
|
||||
// Fallback: original colors
|
||||
return isSubscription.value
|
||||
? 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400'
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
<div>
|
||||
<label class="input-label">
|
||||
Groups
|
||||
<span class="text-gray-400 font-normal">({{ modelValue.length }} selected)</span>
|
||||
<span class="font-normal text-gray-400">({{ modelValue.length }} selected)</span>
|
||||
</label>
|
||||
<div
|
||||
class="grid grid-cols-2 gap-1 max-h-32 overflow-y-auto p-2 border border-gray-200 dark:border-dark-600 rounded-lg bg-gray-50 dark:bg-dark-800"
|
||||
class="grid max-h-32 grid-cols-2 gap-1 overflow-y-auto rounded-lg border border-gray-200 bg-gray-50 p-2 dark:border-dark-600 dark:bg-dark-800"
|
||||
>
|
||||
<label
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.id"
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-white dark:hover:bg-dark-700 cursor-pointer transition-colors"
|
||||
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-white dark:hover:bg-dark-700"
|
||||
:title="`${group.rate_multiplier}x rate · ${group.account_count || 0} accounts`"
|
||||
>
|
||||
<input
|
||||
@@ -18,19 +18,19 @@
|
||||
:value="group.id"
|
||||
:checked="modelValue.includes(group.id)"
|
||||
@change="handleChange(group.id, ($event.target as HTMLInputElement).checked)"
|
||||
class="w-3.5 h-3.5 text-primary-500 border-gray-300 dark:border-dark-500 rounded focus:ring-primary-500 shrink-0"
|
||||
class="h-3.5 w-3.5 shrink-0 rounded border-gray-300 text-primary-500 focus:ring-primary-500 dark:border-dark-500"
|
||||
/>
|
||||
<GroupBadge
|
||||
:name="group.name"
|
||||
:subscription-type="group.subscription_type"
|
||||
:rate-multiplier="group.rate_multiplier"
|
||||
class="flex-1 min-w-0"
|
||||
class="min-w-0 flex-1"
|
||||
/>
|
||||
<span class="text-xs text-gray-400 shrink-0">{{ group.account_count || 0 }}</span>
|
||||
<span class="shrink-0 text-xs text-gray-400">{{ group.account_count || 0 }}</span>
|
||||
</label>
|
||||
<div
|
||||
v-if="filteredGroups.length === 0"
|
||||
class="col-span-2 text-center text-sm text-gray-500 dark:text-gray-400 py-2"
|
||||
class="col-span-2 py-2 text-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
No groups available
|
||||
</div>
|
||||
@@ -59,13 +59,13 @@ const filteredGroups = computed(() => {
|
||||
if (!props.platform) {
|
||||
return props.groups
|
||||
}
|
||||
return props.groups.filter(g => g.platform === props.platform)
|
||||
return props.groups.filter((g) => g.platform === props.platform)
|
||||
})
|
||||
|
||||
const handleChange = (groupId: number, checked: boolean) => {
|
||||
const newValue = checked
|
||||
? [...props.modelValue, groupId]
|
||||
: props.modelValue.filter(id => id !== groupId)
|
||||
: props.modelValue.filter((id) => id !== groupId)
|
||||
emit('update:modelValue', newValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
<div class="relative" ref="dropdownRef">
|
||||
<button
|
||||
@click="toggleDropdown"
|
||||
class="flex items-center gap-1.5 px-2 py-1.5 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-dark-700 transition-colors"
|
||||
class="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
:title="currentLocale?.name"
|
||||
>
|
||||
<span class="text-base">{{ currentLocale?.flag }}</span>
|
||||
<span class="hidden sm:inline">{{ currentLocale?.code.toUpperCase() }}</span>
|
||||
<svg
|
||||
class="w-3.5 h-3.5 text-gray-400 transition-transform duration-200"
|
||||
class="h-3.5 w-3.5 text-gray-400 transition-transform duration-200"
|
||||
:class="{ 'rotate-180': isOpen }"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -22,20 +22,23 @@
|
||||
<transition name="dropdown">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="absolute right-0 mt-1 w-32 rounded-lg bg-white dark:bg-dark-800 shadow-lg border border-gray-200 dark:border-dark-700 overflow-hidden z-50"
|
||||
class="absolute right-0 z-50 mt-1 w-32 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg dark:border-dark-700 dark:bg-dark-800"
|
||||
>
|
||||
<button
|
||||
v-for="locale in availableLocales"
|
||||
:key="locale.code"
|
||||
@click="selectLocale(locale.code)"
|
||||
class="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-dark-700 transition-colors"
|
||||
:class="{ 'bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400': locale.code === currentLocaleCode }"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-dark-700"
|
||||
:class="{
|
||||
'bg-primary-50 text-primary-600 dark:bg-primary-900/20 dark:text-primary-400':
|
||||
locale.code === currentLocaleCode
|
||||
}"
|
||||
>
|
||||
<span class="text-base">{{ locale.flag }}</span>
|
||||
<span>{{ locale.name }}</span>
|
||||
<svg
|
||||
v-if="locale.code === currentLocaleCode"
|
||||
class="w-4 h-4 ml-auto text-primary-500"
|
||||
class="ml-auto h-4 w-4 text-primary-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -60,7 +63,7 @@ const isOpen = ref(false)
|
||||
const dropdownRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const currentLocaleCode = computed(() => locale.value)
|
||||
const currentLocale = computed(() => availableLocales.find(l => l.code === locale.value))
|
||||
const currentLocale = computed(() => availableLocales.find((l) => l.code === locale.value))
|
||||
|
||||
function toggleDropdown() {
|
||||
isOpen.value = !isOpen.value
|
||||
|
||||
@@ -9,24 +9,24 @@
|
||||
@click.self="handleClose"
|
||||
>
|
||||
<!-- Modal panel -->
|
||||
<div
|
||||
:class="['modal-content', sizeClasses]"
|
||||
@click.stop
|
||||
>
|
||||
<div :class="['modal-content', sizeClasses]" @click.stop>
|
||||
<!-- Header -->
|
||||
<div class="modal-header">
|
||||
<h3
|
||||
id="modal-title"
|
||||
class="modal-title"
|
||||
>
|
||||
<h3 id="modal-title" class="modal-title">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<button
|
||||
@click="emit('close')"
|
||||
class="p-2 -mr-2 rounded-xl text-gray-400 dark:text-dark-500 hover:text-gray-600 dark:hover:text-dark-300 hover:bg-gray-100 dark:hover:bg-dark-700 transition-colors"
|
||||
class="-mr-2 rounded-xl p-2 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:text-dark-500 dark:hover:bg-dark-700 dark:hover:text-dark-300"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -38,10 +38,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div
|
||||
v-if="$slots.footer"
|
||||
class="modal-footer"
|
||||
>
|
||||
<div v-if="$slots.footer" class="modal-footer">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between px-4 py-3 bg-white dark:bg-dark-800 border-t border-gray-200 dark:border-dark-700 sm:px-6">
|
||||
<div class="flex items-center justify-between flex-1 sm:hidden">
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 dark:border-dark-700 dark:bg-dark-800 sm:px-6"
|
||||
>
|
||||
<div class="flex flex-1 items-center justify-between sm:hidden">
|
||||
<!-- Mobile pagination -->
|
||||
<button
|
||||
@click="goToPage(page - 1)"
|
||||
:disabled="page === 1"
|
||||
class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-dark-700 border border-gray-300 dark:border-dark-600 rounded-md hover:bg-gray-50 dark:hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
class="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-200 dark:hover:bg-dark-600"
|
||||
>
|
||||
{{ t('pagination.previous') }}
|
||||
</button>
|
||||
@@ -15,7 +17,7 @@
|
||||
<button
|
||||
@click="goToPage(page + 1)"
|
||||
:disabled="page === totalPages"
|
||||
class="relative inline-flex items-center px-4 py-2 ml-3 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-dark-700 border border-gray-300 dark:border-dark-600 rounded-md hover:bg-gray-50 dark:hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
class="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-200 dark:hover:bg-dark-600"
|
||||
>
|
||||
{{ t('pagination.next') }}
|
||||
</button>
|
||||
@@ -36,8 +38,10 @@
|
||||
|
||||
<!-- Page size selector -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('pagination.perPage') }}:</span>
|
||||
<div class="w-20 page-size-select">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300"
|
||||
>{{ t('pagination.perPage') }}:</span
|
||||
>
|
||||
<div class="page-size-select w-20">
|
||||
<Select
|
||||
:model-value="pageSize"
|
||||
:options="pageSizeSelectOptions"
|
||||
@@ -56,10 +60,10 @@
|
||||
<button
|
||||
@click="goToPage(page - 1)"
|
||||
:disabled="page === 1"
|
||||
class="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 bg-white dark:bg-dark-700 border border-gray-300 dark:border-dark-600 rounded-l-md hover:bg-gray-50 dark:hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
class="relative inline-flex items-center rounded-l-md border border-gray-300 bg-white px-2 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600"
|
||||
:aria-label="t('pagination.previous')"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
|
||||
@@ -75,13 +79,15 @@
|
||||
@click="typeof pageNum === 'number' && goToPage(pageNum)"
|
||||
:disabled="typeof pageNum !== 'number'"
|
||||
:class="[
|
||||
'relative inline-flex items-center px-4 py-2 text-sm font-medium border',
|
||||
'relative inline-flex items-center border px-4 py-2 text-sm font-medium',
|
||||
pageNum === page
|
||||
? 'z-10 bg-primary-50 dark:bg-primary-900/30 border-primary-500 text-primary-600 dark:text-primary-400'
|
||||
: 'bg-white dark:bg-dark-700 border-gray-300 dark:border-dark-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-dark-600',
|
||||
? 'z-10 border-primary-500 bg-primary-50 text-primary-600 dark:bg-primary-900/30 dark:text-primary-400'
|
||||
: 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600',
|
||||
typeof pageNum !== 'number' && 'cursor-default'
|
||||
]"
|
||||
:aria-label="typeof pageNum === 'number' ? t('pagination.goToPage', { page: pageNum }) : undefined"
|
||||
:aria-label="
|
||||
typeof pageNum === 'number' ? t('pagination.goToPage', { page: pageNum }) : undefined
|
||||
"
|
||||
:aria-current="pageNum === page ? 'page' : undefined"
|
||||
>
|
||||
{{ pageNum }}
|
||||
@@ -91,10 +97,10 @@
|
||||
<button
|
||||
@click="goToPage(page + 1)"
|
||||
:disabled="page === totalPages"
|
||||
class="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 bg-white dark:bg-dark-700 border border-gray-300 dark:border-dark-600 rounded-r-md hover:bg-gray-50 dark:hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
class="relative inline-flex items-center rounded-r-md border border-gray-300 bg-white px-2 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600"
|
||||
:aria-label="t('pagination.next')"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||
@@ -145,7 +151,7 @@ const toItem = computed(() => {
|
||||
})
|
||||
|
||||
const pageSizeSelectOptions = computed(() => {
|
||||
return props.pageSizeOptions.map(size => ({
|
||||
return props.pageSizeOptions.map((size) => ({
|
||||
value: size,
|
||||
label: String(size)
|
||||
}))
|
||||
@@ -209,6 +215,6 @@ const handlePageSizeChange = (value: string | number | null) => {
|
||||
|
||||
<style scoped>
|
||||
.page-size-select :deep(.select-trigger) {
|
||||
@apply py-1.5 px-3 text-sm;
|
||||
@apply px-3 py-1.5 text-sm;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
<template>
|
||||
<!-- Claude/Anthropic logo -->
|
||||
<svg v-if="platform === 'anthropic'" :class="sizeClass" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="m3.127 10.604 3.135-1.76.053-.153-.053-.085H6.11l-.525-.032-1.791-.048-1.554-.065-1.505-.08-.38-.081L0 7.832l.036-.234.32-.214.455.04 1.009.069 1.513.105 1.097.064 1.626.17h.259l.036-.105-.089-.065-.068-.064-1.566-1.062-1.695-1.121-.887-.646-.48-.327-.243-.306-.104-.67.435-.48.585.04.15.04.593.456 1.267.981 1.654 1.218.242.202.097-.068.012-.049-.109-.181-.9-1.626-.96-1.655-.428-.686-.113-.411a2 2 0 0 1-.068-.484l.496-.674L4.446 0l.662.089.279.242.411.94.666 1.48 1.033 2.014.302.597.162.553.06.17h.105v-.097l.085-1.134.157-1.392.154-1.792.052-.504.25-.605.497-.327.387.186.319.456-.045.294-.19 1.23-.37 1.93-.243 1.29h.142l.161-.16.654-.868 1.097-1.372.484-.545.565-.601.363-.287h.686l.505.751-.226.775-.707.895-.585.759-.839 1.13-.524.904.048.072.125-.012 1.897-.403 1.024-.186 1.223-.21.553.258.06.263-.218.536-1.307.323-1.533.307-2.284.54-.028.02.032.04 1.029.098.44.024h1.077l2.005.15.525.346.315.424-.053.323-.807.411-3.631-.863-.872-.218h-.12v.073l.726.71 1.331 1.202 1.667 1.55.084.383-.214.302-.226-.032-1.464-1.101-.565-.497-1.28-1.077h-.084v.113l.295.432 1.557 2.34.08.718-.112.234-.404.141-.444-.08-.911-1.28-.94-1.44-.759-1.291-.093.053-.448 4.821-.21.246-.484.186-.403-.307-.214-.496.214-.98.258-1.28.21-1.016.19-1.263.112-.42-.008-.028-.092.012-.953 1.307-1.448 1.957-1.146 1.227-.274.109-.477-.247.045-.44.266-.39 1.586-2.018.956-1.25.617-.723-.004-.105h-.036l-4.212 2.736-.75.096-.324-.302.04-.496.154-.162 1.267-.871z"/>
|
||||
<path
|
||||
d="m3.127 10.604 3.135-1.76.053-.153-.053-.085H6.11l-.525-.032-1.791-.048-1.554-.065-1.505-.08-.38-.081L0 7.832l.036-.234.32-.214.455.04 1.009.069 1.513.105 1.097.064 1.626.17h.259l.036-.105-.089-.065-.068-.064-1.566-1.062-1.695-1.121-.887-.646-.48-.327-.243-.306-.104-.67.435-.48.585.04.15.04.593.456 1.267.981 1.654 1.218.242.202.097-.068.012-.049-.109-.181-.9-1.626-.96-1.655-.428-.686-.113-.411a2 2 0 0 1-.068-.484l.496-.674L4.446 0l.662.089.279.242.411.94.666 1.48 1.033 2.014.302.597.162.553.06.17h.105v-.097l.085-1.134.157-1.392.154-1.792.052-.504.25-.605.497-.327.387.186.319.456-.045.294-.19 1.23-.37 1.93-.243 1.29h.142l.161-.16.654-.868 1.097-1.372.484-.545.565-.601.363-.287h.686l.505.751-.226.775-.707.895-.585.759-.839 1.13-.524.904.048.072.125-.012 1.897-.403 1.024-.186 1.223-.21.553.258.06.263-.218.536-1.307.323-1.533.307-2.284.54-.028.02.032.04 1.029.098.44.024h1.077l2.005.15.525.346.315.424-.053.323-.807.411-3.631-.863-.872-.218h-.12v.073l.726.71 1.331 1.202 1.667 1.55.084.383-.214.302-.226-.032-1.464-1.101-.565-.497-1.28-1.077h-.084v.113l.295.432 1.557 2.34.08.718-.112.234-.404.141-.444-.08-.911-1.28-.94-1.44-.759-1.291-.093.053-.448 4.821-.21.246-.484.186-.403-.307-.214-.496.214-.98.258-1.28.21-1.016.19-1.263.112-.42-.008-.028-.092.012-.953 1.307-1.448 1.957-1.146 1.227-.274.109-.477-.247.045-.44.266-.39 1.586-2.018.956-1.25.617-.723-.004-.105h-.036l-4.212 2.736-.75.096-.324-.302.04-.496.154-.162 1.267-.871z"
|
||||
/>
|
||||
</svg>
|
||||
<!-- OpenAI logo -->
|
||||
<svg v-else-if="platform === 'openai'" :class="sizeClass" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z"/>
|
||||
<path
|
||||
d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z"
|
||||
/>
|
||||
</svg>
|
||||
<!-- Gemini logo (simple star) -->
|
||||
<svg v-else-if="platform === 'gemini'" :class="sizeClass" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2l1.89 7.2L21 12l-7.11 2.8L12 22l-1.89-7.2L3 12l7.11-2.8L12 2z" />
|
||||
</svg>
|
||||
<!-- Fallback: generic platform icon -->
|
||||
<svg v-else :class="sizeClass" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
|
||||
<path
|
||||
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,33 +1,56 @@
|
||||
<template>
|
||||
<div class="inline-flex items-center rounded-md overflow-hidden text-xs font-medium">
|
||||
<div class="inline-flex items-center overflow-hidden rounded-md text-xs font-medium">
|
||||
<!-- Platform part -->
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1 px-2 py-1',
|
||||
platformClass
|
||||
]"
|
||||
>
|
||||
<span :class="['inline-flex items-center gap-1 px-2 py-1', platformClass]">
|
||||
<PlatformIcon :platform="platform" size="xs" />
|
||||
<span>{{ platformLabel }}</span>
|
||||
</span>
|
||||
<!-- Type part -->
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1 px-1.5 py-1',
|
||||
typeClass
|
||||
]"
|
||||
>
|
||||
<span :class="['inline-flex items-center gap-1 px-1.5 py-1', typeClass]">
|
||||
<!-- OAuth icon -->
|
||||
<svg v-if="type === 'oauth'" class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
<svg
|
||||
v-if="type === 'oauth'"
|
||||
class="h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
|
||||
/>
|
||||
</svg>
|
||||
<!-- Setup Token icon -->
|
||||
<svg v-else-if="type === 'setup-token'" class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||
<svg
|
||||
v-else-if="type === 'setup-token'"
|
||||
class="h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
|
||||
/>
|
||||
</svg>
|
||||
<!-- API Key icon -->
|
||||
<svg v-else class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
|
||||
<svg
|
||||
v-else
|
||||
class="h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{{ typeLabel }}</span>
|
||||
</span>
|
||||
@@ -47,15 +70,21 @@ interface Props {
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const platformLabel = computed(() => {
|
||||
return props.platform === 'anthropic' ? 'Anthropic' : 'OpenAI'
|
||||
if (props.platform === 'anthropic') return 'Anthropic'
|
||||
if (props.platform === 'openai') return 'OpenAI'
|
||||
return 'Gemini'
|
||||
})
|
||||
|
||||
const typeLabel = computed(() => {
|
||||
switch (props.type) {
|
||||
case 'oauth': return 'OAuth'
|
||||
case 'setup-token': return 'Token'
|
||||
case 'apikey': return 'Key'
|
||||
default: return props.type
|
||||
case 'oauth':
|
||||
return 'OAuth'
|
||||
case 'setup-token':
|
||||
return 'Token'
|
||||
case 'apikey':
|
||||
return 'Key'
|
||||
default:
|
||||
return props.type
|
||||
}
|
||||
})
|
||||
|
||||
@@ -63,13 +92,19 @@ const platformClass = computed(() => {
|
||||
if (props.platform === 'anthropic') {
|
||||
return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
|
||||
}
|
||||
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||
if (props.platform === 'openai') {
|
||||
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||
}
|
||||
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
})
|
||||
|
||||
const typeClass = computed(() => {
|
||||
if (props.platform === 'anthropic') {
|
||||
return 'bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400'
|
||||
}
|
||||
return 'bg-emerald-100 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||
if (props.platform === 'openai') {
|
||||
return 'bg-emerald-100 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||
}
|
||||
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
</span>
|
||||
<span class="select-icon">
|
||||
<svg
|
||||
:class="['w-5 h-5 transition-transform duration-200', isOpen && 'rotate-180']"
|
||||
:class="['h-5 w-5 transition-transform duration-200', isOpen && 'rotate-180']"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -27,15 +27,22 @@
|
||||
</button>
|
||||
|
||||
<Transition name="select-dropdown">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="select-dropdown"
|
||||
>
|
||||
<div v-if="isOpen" class="select-dropdown">
|
||||
<!-- Search and Batch Test Header -->
|
||||
<div class="select-header">
|
||||
<div class="select-search">
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
<svg
|
||||
class="h-4 w-4 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
@@ -54,12 +61,34 @@
|
||||
class="batch-test-btn"
|
||||
:title="t('admin.proxies.batchTest')"
|
||||
>
|
||||
<svg v-if="batchTesting" class="w-4 h-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 v-if="batchTesting" 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 v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z" />
|
||||
<svg
|
||||
v-else
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -69,15 +98,12 @@
|
||||
<!-- No Proxy option -->
|
||||
<div
|
||||
@click="selectOption(null)"
|
||||
:class="[
|
||||
'select-option',
|
||||
modelValue === null && 'select-option-selected'
|
||||
]"
|
||||
:class="['select-option', modelValue === null && 'select-option-selected']"
|
||||
>
|
||||
<span class="select-option-label">{{ t('admin.accounts.noProxy') }}</span>
|
||||
<svg
|
||||
v-if="modelValue === null"
|
||||
class="w-4 h-4 text-primary-500"
|
||||
class="h-4 w-4 text-primary-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -92,18 +118,15 @@
|
||||
v-for="proxy in filteredProxies"
|
||||
:key="proxy.id"
|
||||
@click="selectOption(proxy.id)"
|
||||
:class="[
|
||||
'select-option',
|
||||
modelValue === proxy.id && 'select-option-selected'
|
||||
]"
|
||||
:class="['select-option', modelValue === proxy.id && 'select-option-selected']"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate font-medium">{{ proxy.name }}</span>
|
||||
<!-- Account count badge -->
|
||||
<span
|
||||
v-if="proxy.account_count !== undefined"
|
||||
class="flex-shrink-0 inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-gray-100 dark:bg-dark-600 text-gray-600 dark:text-gray-400"
|
||||
class="inline-flex flex-shrink-0 items-center rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600 dark:bg-dark-600 dark:text-gray-400"
|
||||
>
|
||||
{{ proxy.account_count }}
|
||||
</span>
|
||||
@@ -111,20 +134,24 @@
|
||||
<template v-if="testResults[proxy.id]">
|
||||
<span
|
||||
v-if="testResults[proxy.id].success"
|
||||
class="flex-shrink-0 inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400"
|
||||
class="inline-flex flex-shrink-0 items-center gap-1 rounded bg-emerald-100 px-1.5 py-0.5 text-xs text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400"
|
||||
>
|
||||
<span v-if="testResults[proxy.id].country">{{ testResults[proxy.id].country }}</span>
|
||||
<span v-if="testResults[proxy.id].latency_ms">{{ testResults[proxy.id].latency_ms }}ms</span>
|
||||
<span v-if="testResults[proxy.id].country">{{
|
||||
testResults[proxy.id].country
|
||||
}}</span>
|
||||
<span v-if="testResults[proxy.id].latency_ms"
|
||||
>{{ testResults[proxy.id].latency_ms }}ms</span
|
||||
>
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="flex-shrink-0 inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400"
|
||||
class="inline-flex flex-shrink-0 items-center rounded bg-red-100 px-1.5 py-0.5 text-xs text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||||
>
|
||||
{{ t('admin.proxies.testFailed') }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
<div class="truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ proxy.protocol }}://{{ proxy.host }}:{{ proxy.port }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,18 +164,45 @@
|
||||
class="test-btn"
|
||||
:title="t('admin.proxies.testConnection')"
|
||||
>
|
||||
<svg v-if="testingProxyIds.has(proxy.id)" class="w-3.5 h-3.5 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
|
||||
v-if="testingProxyIds.has(proxy.id)"
|
||||
class="h-3.5 w-3.5 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<svg v-else class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z" />
|
||||
<svg
|
||||
v-else
|
||||
class="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<svg
|
||||
v-if="modelValue === proxy.id"
|
||||
class="w-4 h-4 text-primary-500 flex-shrink-0"
|
||||
class="h-4 w-4 flex-shrink-0 text-primary-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -193,7 +247,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
disabled: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -212,7 +266,7 @@ const batchTesting = ref(false)
|
||||
|
||||
const selectedProxy = computed(() => {
|
||||
if (props.modelValue === null) return null
|
||||
return props.proxies.find(p => p.id === props.modelValue) || null
|
||||
return props.proxies.find((p) => p.id === props.modelValue) || null
|
||||
})
|
||||
|
||||
const selectedLabel = computed(() => {
|
||||
@@ -228,7 +282,7 @@ const filteredProxies = computed(() => {
|
||||
return props.proxies
|
||||
}
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return props.proxies.filter(proxy => {
|
||||
return props.proxies.filter((proxy) => {
|
||||
const name = proxy.name.toLowerCase()
|
||||
const host = proxy.host.toLowerCase()
|
||||
return name.includes(query) || host.includes(query)
|
||||
@@ -320,27 +374,27 @@ onUnmounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.select-trigger {
|
||||
@apply w-full flex items-center justify-between gap-2;
|
||||
@apply px-4 py-2.5 rounded-xl text-sm;
|
||||
@apply flex w-full items-center justify-between gap-2;
|
||||
@apply rounded-xl px-4 py-2.5 text-sm;
|
||||
@apply bg-white dark:bg-dark-800;
|
||||
@apply border border-gray-200 dark:border-dark-600;
|
||||
@apply text-gray-900 dark:text-gray-100;
|
||||
@apply transition-all duration-200;
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-primary-500/30 focus:border-primary-500;
|
||||
@apply focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30;
|
||||
@apply hover:border-gray-300 dark:hover:border-dark-500;
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
.select-trigger-open {
|
||||
@apply ring-2 ring-primary-500/30 border-primary-500;
|
||||
@apply border-primary-500 ring-2 ring-primary-500/30;
|
||||
}
|
||||
|
||||
.select-trigger-disabled {
|
||||
@apply bg-gray-100 dark:bg-dark-900 cursor-not-allowed opacity-60;
|
||||
@apply cursor-not-allowed bg-gray-100 opacity-60 dark:bg-dark-900;
|
||||
}
|
||||
|
||||
.select-value {
|
||||
@apply flex-1 text-left truncate;
|
||||
@apply flex-1 truncate text-left;
|
||||
}
|
||||
|
||||
.select-icon {
|
||||
@@ -348,7 +402,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.select-dropdown {
|
||||
@apply absolute z-[100] w-full mt-2;
|
||||
@apply absolute z-[100] mt-2 w-full;
|
||||
@apply bg-white dark:bg-dark-800;
|
||||
@apply rounded-xl;
|
||||
@apply border border-gray-200 dark:border-dark-700;
|
||||
@@ -362,7 +416,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.select-search {
|
||||
@apply flex-1 flex items-center gap-2;
|
||||
@apply flex flex-1 items-center gap-2;
|
||||
}
|
||||
|
||||
.select-search-input {
|
||||
@@ -373,10 +427,10 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.batch-test-btn {
|
||||
@apply flex-shrink-0 p-1.5 rounded-lg;
|
||||
@apply flex-shrink-0 rounded-lg p-1.5;
|
||||
@apply text-gray-500 hover:text-emerald-600 dark:hover:text-emerald-400;
|
||||
@apply hover:bg-emerald-50 dark:hover:bg-emerald-900/20;
|
||||
@apply transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
@apply transition-colors disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
|
||||
.select-options {
|
||||
@@ -406,10 +460,10 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.test-btn {
|
||||
@apply flex-shrink-0 p-1 rounded;
|
||||
@apply flex-shrink-0 rounded p-1;
|
||||
@apply text-gray-400 hover:text-emerald-600 dark:hover:text-emerald-400;
|
||||
@apply hover:bg-emerald-50 dark:hover:bg-emerald-900/20;
|
||||
@apply transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
@apply transition-colors disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
|
||||
/* Dropdown animation */
|
||||
|
||||
@@ -5,18 +5,22 @@ This directory contains reusable Vue 3 components built with Composition API, Ty
|
||||
## Components
|
||||
|
||||
### DataTable.vue
|
||||
|
||||
A generic data table component with sorting, loading states, and custom cell rendering.
|
||||
|
||||
**Props:**
|
||||
|
||||
- `columns: Column[]` - Array of column definitions with key, label, sortable, and formatter
|
||||
- `data: any[]` - Array of data objects to display
|
||||
- `loading?: boolean` - Show loading skeleton
|
||||
|
||||
**Slots:**
|
||||
|
||||
- `empty` - Custom empty state content
|
||||
- `cell-{key}` - Custom cell renderer for specific column (receives `row` and `value`)
|
||||
|
||||
**Usage:**
|
||||
|
||||
```vue
|
||||
<DataTable
|
||||
:columns="[
|
||||
@@ -36,19 +40,23 @@ A generic data table component with sorting, loading states, and custom cell ren
|
||||
---
|
||||
|
||||
### Pagination.vue
|
||||
|
||||
Pagination component with page numbers, navigation, and page size selector.
|
||||
|
||||
**Props:**
|
||||
|
||||
- `total: number` - Total number of items
|
||||
- `page: number` - Current page (1-indexed)
|
||||
- `pageSize: number` - Items per page
|
||||
- `pageSizeOptions?: number[]` - Available page size options (default: [10, 20, 50, 100])
|
||||
|
||||
**Events:**
|
||||
|
||||
- `update:page` - Emitted when page changes
|
||||
- `update:pageSize` - Emitted when page size changes
|
||||
|
||||
**Usage:**
|
||||
|
||||
```vue
|
||||
<Pagination
|
||||
:total="totalUsers"
|
||||
@@ -62,9 +70,11 @@ Pagination component with page numbers, navigation, and page size selector.
|
||||
---
|
||||
|
||||
### Modal.vue
|
||||
|
||||
Modal dialog with customizable size and close behavior.
|
||||
|
||||
**Props:**
|
||||
|
||||
- `show: boolean` - Control modal visibility
|
||||
- `title: string` - Modal title
|
||||
- `size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'` - Modal size (default: 'md')
|
||||
@@ -72,13 +82,16 @@ Modal dialog with customizable size and close behavior.
|
||||
- `closeOnClickOutside?: boolean` - Close on backdrop click (default: true)
|
||||
|
||||
**Events:**
|
||||
|
||||
- `close` - Emitted when modal should close
|
||||
|
||||
**Slots:**
|
||||
|
||||
- `default` - Modal body content
|
||||
- `footer` - Modal footer content
|
||||
|
||||
**Usage:**
|
||||
|
||||
```vue
|
||||
<Modal :show="showModal" title="Edit User" size="lg" @close="showModal = false">
|
||||
<form @submit.prevent="saveUser">
|
||||
@@ -95,9 +108,11 @@ Modal dialog with customizable size and close behavior.
|
||||
---
|
||||
|
||||
### ConfirmDialog.vue
|
||||
|
||||
Confirmation dialog built on top of Modal component.
|
||||
|
||||
**Props:**
|
||||
|
||||
- `show: boolean` - Control dialog visibility
|
||||
- `title: string` - Dialog title
|
||||
- `message: string` - Confirmation message
|
||||
@@ -106,10 +121,12 @@ Confirmation dialog built on top of Modal component.
|
||||
- `danger?: boolean` - Use danger/red styling (default: false)
|
||||
|
||||
**Events:**
|
||||
|
||||
- `confirm` - Emitted when user confirms
|
||||
- `cancel` - Emitted when user cancels
|
||||
|
||||
**Usage:**
|
||||
|
||||
```vue
|
||||
<ConfirmDialog
|
||||
:show="showDeleteConfirm"
|
||||
@@ -126,9 +143,11 @@ Confirmation dialog built on top of Modal component.
|
||||
---
|
||||
|
||||
### StatCard.vue
|
||||
|
||||
Statistics card component for displaying metrics with optional change indicators.
|
||||
|
||||
**Props:**
|
||||
|
||||
- `title: string` - Card title
|
||||
- `value: number | string` - Main value to display
|
||||
- `icon?: Component` - Icon component
|
||||
@@ -137,22 +156,19 @@ Statistics card component for displaying metrics with optional change indicators
|
||||
- `formatValue?: (value) => string` - Custom value formatter
|
||||
|
||||
**Usage:**
|
||||
|
||||
```vue
|
||||
<StatCard
|
||||
title="Total Users"
|
||||
:value="1234"
|
||||
:icon="UserIcon"
|
||||
:change="12.5"
|
||||
change-type="up"
|
||||
/>
|
||||
<StatCard title="Total Users" :value="1234" :icon="UserIcon" :change="12.5" change-type="up" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Toast.vue
|
||||
|
||||
Toast notification component that automatically displays toasts from the app store.
|
||||
|
||||
**Usage:**
|
||||
|
||||
```vue
|
||||
<!-- Add once in App.vue or layout -->
|
||||
<Toast />
|
||||
@@ -180,13 +196,16 @@ appStore.addToast({
|
||||
---
|
||||
|
||||
### LoadingSpinner.vue
|
||||
|
||||
Simple animated loading spinner.
|
||||
|
||||
**Props:**
|
||||
|
||||
- `size?: 'sm' | 'md' | 'lg' | 'xl'` - Spinner size (default: 'md')
|
||||
- `color?: 'primary' | 'secondary' | 'white' | 'gray'` - Spinner color (default: 'primary')
|
||||
|
||||
**Usage:**
|
||||
|
||||
```vue
|
||||
<LoadingSpinner size="lg" color="primary" />
|
||||
```
|
||||
@@ -194,9 +213,11 @@ Simple animated loading spinner.
|
||||
---
|
||||
|
||||
### EmptyState.vue
|
||||
|
||||
Empty state placeholder with icon, message, and optional action button.
|
||||
|
||||
**Props:**
|
||||
|
||||
- `icon?: Component` - Icon component
|
||||
- `title: string` - Empty state title
|
||||
- `description: string` - Empty state description
|
||||
@@ -205,10 +226,12 @@ Empty state placeholder with icon, message, and optional action button.
|
||||
- `actionIcon?: boolean` - Show plus icon in button (default: true)
|
||||
|
||||
**Slots:**
|
||||
|
||||
- `icon` - Custom icon content
|
||||
- `action` - Custom action button/link
|
||||
|
||||
**Usage:**
|
||||
|
||||
```vue
|
||||
<EmptyState
|
||||
title="No users found"
|
||||
@@ -235,6 +258,7 @@ import DataTable from '@/components/common/DataTable.vue'
|
||||
## Features
|
||||
|
||||
All components include:
|
||||
|
||||
- **TypeScript support** with proper type definitions
|
||||
- **Accessibility** with ARIA attributes and keyboard navigation
|
||||
- **Responsive design** with mobile-friendly layouts
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</span>
|
||||
<span class="select-icon">
|
||||
<svg
|
||||
:class="['w-5 h-5 transition-transform duration-200', isOpen && 'rotate-180']"
|
||||
:class="['h-5 w-5 transition-transform duration-200', isOpen && 'rotate-180']"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -30,14 +30,21 @@
|
||||
</button>
|
||||
|
||||
<Transition name="select-dropdown">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="select-dropdown"
|
||||
>
|
||||
<div v-if="isOpen" class="select-dropdown">
|
||||
<!-- Search input -->
|
||||
<div v-if="searchable" class="select-search">
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
<svg
|
||||
class="h-4 w-4 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
@@ -55,16 +62,13 @@
|
||||
v-for="option in filteredOptions"
|
||||
:key="getOptionValue(option) ?? undefined"
|
||||
@click="selectOption(option)"
|
||||
:class="[
|
||||
'select-option',
|
||||
isSelected(option) && 'select-option-selected'
|
||||
]"
|
||||
:class="['select-option', isSelected(option) && 'select-option-selected']"
|
||||
>
|
||||
<slot name="option" :option="option" :selected="isSelected(option)">
|
||||
<span class="select-option-label">{{ getOptionLabel(option) }}</span>
|
||||
<svg
|
||||
v-if="isSelected(option)"
|
||||
class="w-4 h-4 text-primary-500"
|
||||
class="h-4 w-4 text-primary-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -126,7 +130,9 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
// Use computed for i18n default values
|
||||
const placeholderText = computed(() => props.placeholder ?? t('common.selectOption'))
|
||||
const searchPlaceholderText = computed(() => props.searchPlaceholder ?? t('common.searchPlaceholder'))
|
||||
const searchPlaceholderText = computed(
|
||||
() => props.searchPlaceholder ?? t('common.searchPlaceholder')
|
||||
)
|
||||
const emptyTextDisplay = computed(() => props.emptyText ?? t('common.noOptionsFound'))
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
@@ -136,7 +142,9 @@ const searchQuery = ref('')
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const searchInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const getOptionValue = (option: SelectOption | Record<string, unknown>): string | number | null | undefined => {
|
||||
const getOptionValue = (
|
||||
option: SelectOption | Record<string, unknown>
|
||||
): string | number | null | undefined => {
|
||||
if (typeof option === 'object' && option !== null) {
|
||||
return option[props.valueKey] as string | number | null | undefined
|
||||
}
|
||||
@@ -151,7 +159,7 @@ const getOptionLabel = (option: SelectOption | Record<string, unknown>): string
|
||||
}
|
||||
|
||||
const selectedOption = computed(() => {
|
||||
return props.options.find(opt => getOptionValue(opt) === props.modelValue) || null
|
||||
return props.options.find((opt) => getOptionValue(opt) === props.modelValue) || null
|
||||
})
|
||||
|
||||
const selectedLabel = computed(() => {
|
||||
@@ -166,7 +174,7 @@ const filteredOptions = computed(() => {
|
||||
return props.options
|
||||
}
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return props.options.filter(opt => {
|
||||
return props.options.filter((opt) => {
|
||||
const label = getOptionLabel(opt).toLowerCase()
|
||||
return label.includes(query)
|
||||
})
|
||||
@@ -227,31 +235,31 @@ onUnmounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.select-trigger {
|
||||
@apply w-full flex items-center justify-between gap-2;
|
||||
@apply px-4 py-2.5 rounded-xl text-sm;
|
||||
@apply flex w-full items-center justify-between gap-2;
|
||||
@apply rounded-xl px-4 py-2.5 text-sm;
|
||||
@apply bg-white dark:bg-dark-800;
|
||||
@apply border border-gray-200 dark:border-dark-600;
|
||||
@apply text-gray-900 dark:text-gray-100;
|
||||
@apply transition-all duration-200;
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-primary-500/30 focus:border-primary-500;
|
||||
@apply focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30;
|
||||
@apply hover:border-gray-300 dark:hover:border-dark-500;
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
.select-trigger-open {
|
||||
@apply ring-2 ring-primary-500/30 border-primary-500;
|
||||
@apply border-primary-500 ring-2 ring-primary-500/30;
|
||||
}
|
||||
|
||||
.select-trigger-error {
|
||||
@apply border-red-500 focus:ring-red-500/30 focus:border-red-500;
|
||||
@apply border-red-500 focus:border-red-500 focus:ring-red-500/30;
|
||||
}
|
||||
|
||||
.select-trigger-disabled {
|
||||
@apply bg-gray-100 dark:bg-dark-900 cursor-not-allowed opacity-60;
|
||||
@apply cursor-not-allowed bg-gray-100 opacity-60 dark:bg-dark-900;
|
||||
}
|
||||
|
||||
.select-value {
|
||||
@apply flex-1 text-left truncate;
|
||||
@apply flex-1 truncate text-left;
|
||||
}
|
||||
|
||||
.select-icon {
|
||||
@@ -259,7 +267,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.select-dropdown {
|
||||
@apply absolute z-[100] w-full mt-2;
|
||||
@apply absolute z-[100] mt-2 w-full;
|
||||
@apply bg-white dark:bg-dark-800;
|
||||
@apply rounded-xl;
|
||||
@apply border border-gray-200 dark:border-dark-700;
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
<template>
|
||||
<div class="stat-card">
|
||||
<div :class="['stat-icon', iconClass]">
|
||||
<component
|
||||
v-if="icon"
|
||||
:is="icon"
|
||||
class="w-6 h-6"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<component v-if="icon" :is="icon" class="h-6 w-6" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="stat-label truncate">{{ title }}</p>
|
||||
<div class="flex items-baseline gap-2 mt-1">
|
||||
<div class="mt-1 flex items-baseline gap-2">
|
||||
<p class="stat-value">{{ formattedValue }}</p>
|
||||
<span
|
||||
v-if="change !== undefined"
|
||||
:class="['stat-trend', trendClass]"
|
||||
>
|
||||
<span v-if="change !== undefined" :class="['stat-trend', trendClass]">
|
||||
<svg
|
||||
v-if="changeType !== 'neutral'"
|
||||
:class="['w-3 h-3', changeType === 'down' && 'rotate-180']"
|
||||
:class="['h-3 w-3', changeType === 'down' && 'rotate-180']"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
|
||||
@@ -3,11 +3,21 @@
|
||||
<!-- Mini Progress Display -->
|
||||
<button
|
||||
@click="toggleTooltip"
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-xl bg-purple-50 dark:bg-purple-900/20 hover:bg-purple-100 dark:hover:bg-purple-900/30 transition-colors cursor-pointer"
|
||||
class="flex cursor-pointer items-center gap-2 rounded-xl bg-purple-50 px-3 py-1.5 transition-colors hover:bg-purple-100 dark:bg-purple-900/20 dark:hover:bg-purple-900/30"
|
||||
:title="t('subscriptionProgress.viewDetails')"
|
||||
>
|
||||
<svg class="w-4 h-4 text-purple-600 dark:text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
|
||||
<svg
|
||||
class="h-4 w-4 text-purple-600 dark:text-purple-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z"
|
||||
/>
|
||||
</svg>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<!-- Combined progress indicator -->
|
||||
@@ -15,7 +25,7 @@
|
||||
<div
|
||||
v-for="(sub, index) in displaySubscriptions.slice(0, 3)"
|
||||
:key="index"
|
||||
class="w-2 h-2 rounded-full"
|
||||
class="h-2 w-2 rounded-full"
|
||||
:class="getProgressDotClass(sub)"
|
||||
></div>
|
||||
</div>
|
||||
@@ -29,13 +39,13 @@
|
||||
<transition name="dropdown">
|
||||
<div
|
||||
v-if="tooltipOpen"
|
||||
class="absolute right-0 mt-2 w-[340px] bg-white dark:bg-dark-800 rounded-xl shadow-xl border border-gray-200 dark:border-dark-700 z-50 overflow-hidden"
|
||||
class="absolute right-0 z-50 mt-2 w-[340px] overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-dark-700 dark:bg-dark-800"
|
||||
>
|
||||
<div class="p-3 border-b border-gray-100 dark:border-dark-700">
|
||||
<div class="border-b border-gray-100 p-3 dark:border-dark-700">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('subscriptionProgress.title') }}
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400 mt-0.5">
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-dark-400">
|
||||
{{ t('subscriptionProgress.activeCount', { count: activeSubscriptions.length }) }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -44,9 +54,9 @@
|
||||
<div
|
||||
v-for="subscription in displaySubscriptions"
|
||||
:key="subscription.id"
|
||||
class="p-3 border-b border-gray-50 dark:border-dark-700/50 last:border-b-0"
|
||||
class="border-b border-gray-50 p-3 last:border-b-0 dark:border-dark-700/50"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ subscription.group?.name || `Group #${subscription.group_id}` }}
|
||||
</span>
|
||||
@@ -62,55 +72,100 @@
|
||||
<!-- Progress bars -->
|
||||
<div class="space-y-1.5">
|
||||
<div v-if="subscription.group?.daily_limit_usd" class="flex items-center gap-2">
|
||||
<span class="text-[10px] text-gray-500 w-8 flex-shrink-0">{{ t('subscriptionProgress.daily') }}</span>
|
||||
<div class="flex-1 min-w-0 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
|
||||
<span class="w-8 flex-shrink-0 text-[10px] text-gray-500">{{
|
||||
t('subscriptionProgress.daily')
|
||||
}}</span>
|
||||
<div class="h-1.5 min-w-0 flex-1 rounded-full bg-gray-200 dark:bg-dark-600">
|
||||
<div
|
||||
class="h-1.5 rounded-full transition-all"
|
||||
:class="getProgressBarClass(subscription.daily_usage_usd, subscription.group?.daily_limit_usd)"
|
||||
:style="{ width: getProgressWidth(subscription.daily_usage_usd, subscription.group?.daily_limit_usd) }"
|
||||
:class="
|
||||
getProgressBarClass(
|
||||
subscription.daily_usage_usd,
|
||||
subscription.group?.daily_limit_usd
|
||||
)
|
||||
"
|
||||
:style="{
|
||||
width: getProgressWidth(
|
||||
subscription.daily_usage_usd,
|
||||
subscription.group?.daily_limit_usd
|
||||
)
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-[10px] text-gray-500 w-24 text-right flex-shrink-0">
|
||||
{{ formatUsage(subscription.daily_usage_usd, subscription.group?.daily_limit_usd) }}
|
||||
<span class="w-24 flex-shrink-0 text-right text-[10px] text-gray-500">
|
||||
{{
|
||||
formatUsage(subscription.daily_usage_usd, subscription.group?.daily_limit_usd)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="subscription.group?.weekly_limit_usd" class="flex items-center gap-2">
|
||||
<span class="text-[10px] text-gray-500 w-8 flex-shrink-0">{{ t('subscriptionProgress.weekly') }}</span>
|
||||
<div class="flex-1 min-w-0 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
|
||||
<span class="w-8 flex-shrink-0 text-[10px] text-gray-500">{{
|
||||
t('subscriptionProgress.weekly')
|
||||
}}</span>
|
||||
<div class="h-1.5 min-w-0 flex-1 rounded-full bg-gray-200 dark:bg-dark-600">
|
||||
<div
|
||||
class="h-1.5 rounded-full transition-all"
|
||||
:class="getProgressBarClass(subscription.weekly_usage_usd, subscription.group?.weekly_limit_usd)"
|
||||
:style="{ width: getProgressWidth(subscription.weekly_usage_usd, subscription.group?.weekly_limit_usd) }"
|
||||
:class="
|
||||
getProgressBarClass(
|
||||
subscription.weekly_usage_usd,
|
||||
subscription.group?.weekly_limit_usd
|
||||
)
|
||||
"
|
||||
:style="{
|
||||
width: getProgressWidth(
|
||||
subscription.weekly_usage_usd,
|
||||
subscription.group?.weekly_limit_usd
|
||||
)
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-[10px] text-gray-500 w-24 text-right flex-shrink-0">
|
||||
{{ formatUsage(subscription.weekly_usage_usd, subscription.group?.weekly_limit_usd) }}
|
||||
<span class="w-24 flex-shrink-0 text-right text-[10px] text-gray-500">
|
||||
{{
|
||||
formatUsage(subscription.weekly_usage_usd, subscription.group?.weekly_limit_usd)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="subscription.group?.monthly_limit_usd" class="flex items-center gap-2">
|
||||
<span class="text-[10px] text-gray-500 w-8 flex-shrink-0">{{ t('subscriptionProgress.monthly') }}</span>
|
||||
<div class="flex-1 min-w-0 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
|
||||
<span class="w-8 flex-shrink-0 text-[10px] text-gray-500">{{
|
||||
t('subscriptionProgress.monthly')
|
||||
}}</span>
|
||||
<div class="h-1.5 min-w-0 flex-1 rounded-full bg-gray-200 dark:bg-dark-600">
|
||||
<div
|
||||
class="h-1.5 rounded-full transition-all"
|
||||
:class="getProgressBarClass(subscription.monthly_usage_usd, subscription.group?.monthly_limit_usd)"
|
||||
:style="{ width: getProgressWidth(subscription.monthly_usage_usd, subscription.group?.monthly_limit_usd) }"
|
||||
:class="
|
||||
getProgressBarClass(
|
||||
subscription.monthly_usage_usd,
|
||||
subscription.group?.monthly_limit_usd
|
||||
)
|
||||
"
|
||||
:style="{
|
||||
width: getProgressWidth(
|
||||
subscription.monthly_usage_usd,
|
||||
subscription.group?.monthly_limit_usd
|
||||
)
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-[10px] text-gray-500 w-24 text-right flex-shrink-0">
|
||||
{{ formatUsage(subscription.monthly_usage_usd, subscription.group?.monthly_limit_usd) }}
|
||||
<span class="w-24 flex-shrink-0 text-right text-[10px] text-gray-500">
|
||||
{{
|
||||
formatUsage(
|
||||
subscription.monthly_usage_usd,
|
||||
subscription.group?.monthly_limit_usd
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-2 border-t border-gray-100 dark:border-dark-700">
|
||||
<div class="border-t border-gray-100 p-2 dark:border-dark-700">
|
||||
<router-link
|
||||
to="/subscriptions"
|
||||
@click="closeTooltip"
|
||||
class="block w-full text-center text-xs text-primary-600 dark:text-primary-400 hover:underline py-1"
|
||||
class="block w-full py-1 text-center text-xs text-primary-600 hover:underline dark:text-primary-400"
|
||||
>
|
||||
{{ t('subscriptionProgress.viewAll') }}
|
||||
</router-link>
|
||||
@@ -121,136 +176,136 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import subscriptionsAPI from '@/api/subscriptions';
|
||||
import type { UserSubscription } from '@/types';
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import subscriptionsAPI from '@/api/subscriptions'
|
||||
import type { UserSubscription } from '@/types'
|
||||
|
||||
const { t } = useI18n();
|
||||
const { t } = useI18n()
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
const tooltipOpen = ref(false);
|
||||
const activeSubscriptions = ref<UserSubscription[]>([]);
|
||||
const loading = ref(false);
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const tooltipOpen = ref(false)
|
||||
const activeSubscriptions = ref<UserSubscription[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const hasActiveSubscriptions = computed(() => activeSubscriptions.value.length > 0);
|
||||
const hasActiveSubscriptions = computed(() => activeSubscriptions.value.length > 0)
|
||||
|
||||
const displaySubscriptions = computed(() => {
|
||||
// Sort by most usage (highest percentage first)
|
||||
return [...activeSubscriptions.value].sort((a, b) => {
|
||||
const aMax = getMaxUsagePercentage(a);
|
||||
const bMax = getMaxUsagePercentage(b);
|
||||
return bMax - aMax;
|
||||
});
|
||||
});
|
||||
const aMax = getMaxUsagePercentage(a)
|
||||
const bMax = getMaxUsagePercentage(b)
|
||||
return bMax - aMax
|
||||
})
|
||||
})
|
||||
|
||||
function getMaxUsagePercentage(sub: UserSubscription): number {
|
||||
const percentages: number[] = [];
|
||||
const percentages: number[] = []
|
||||
if (sub.group?.daily_limit_usd) {
|
||||
percentages.push((sub.daily_usage_usd || 0) / sub.group.daily_limit_usd * 100);
|
||||
percentages.push(((sub.daily_usage_usd || 0) / sub.group.daily_limit_usd) * 100)
|
||||
}
|
||||
if (sub.group?.weekly_limit_usd) {
|
||||
percentages.push((sub.weekly_usage_usd || 0) / sub.group.weekly_limit_usd * 100);
|
||||
percentages.push(((sub.weekly_usage_usd || 0) / sub.group.weekly_limit_usd) * 100)
|
||||
}
|
||||
if (sub.group?.monthly_limit_usd) {
|
||||
percentages.push((sub.monthly_usage_usd || 0) / sub.group.monthly_limit_usd * 100);
|
||||
percentages.push(((sub.monthly_usage_usd || 0) / sub.group.monthly_limit_usd) * 100)
|
||||
}
|
||||
return percentages.length > 0 ? Math.max(...percentages) : 0;
|
||||
return percentages.length > 0 ? Math.max(...percentages) : 0
|
||||
}
|
||||
|
||||
function getProgressDotClass(sub: UserSubscription): string {
|
||||
const maxPercentage = getMaxUsagePercentage(sub);
|
||||
if (maxPercentage >= 90) return 'bg-red-500';
|
||||
if (maxPercentage >= 70) return 'bg-orange-500';
|
||||
return 'bg-green-500';
|
||||
const maxPercentage = getMaxUsagePercentage(sub)
|
||||
if (maxPercentage >= 90) return 'bg-red-500'
|
||||
if (maxPercentage >= 70) return 'bg-orange-500'
|
||||
return 'bg-green-500'
|
||||
}
|
||||
|
||||
function getProgressBarClass(used: number | undefined, limit: number | null | undefined): string {
|
||||
if (!limit || limit === 0) return 'bg-gray-400';
|
||||
const percentage = ((used || 0) / limit) * 100;
|
||||
if (percentage >= 90) return 'bg-red-500';
|
||||
if (percentage >= 70) return 'bg-orange-500';
|
||||
return 'bg-green-500';
|
||||
if (!limit || limit === 0) return 'bg-gray-400'
|
||||
const percentage = ((used || 0) / limit) * 100
|
||||
if (percentage >= 90) return 'bg-red-500'
|
||||
if (percentage >= 70) return 'bg-orange-500'
|
||||
return 'bg-green-500'
|
||||
}
|
||||
|
||||
function getProgressWidth(used: number | undefined, limit: number | null | undefined): string {
|
||||
if (!limit || limit === 0) return '0%';
|
||||
const percentage = Math.min(((used || 0) / limit) * 100, 100);
|
||||
return `${percentage}%`;
|
||||
if (!limit || limit === 0) return '0%'
|
||||
const percentage = Math.min(((used || 0) / limit) * 100, 100)
|
||||
return `${percentage}%`
|
||||
}
|
||||
|
||||
function formatUsage(used: number | undefined, limit: number | null | undefined): string {
|
||||
const usedValue = (used || 0).toFixed(2);
|
||||
const limitValue = limit?.toFixed(2) || '∞';
|
||||
return `$${usedValue}/$${limitValue}`;
|
||||
const usedValue = (used || 0).toFixed(2)
|
||||
const limitValue = limit?.toFixed(2) || '∞'
|
||||
return `$${usedValue}/$${limitValue}`
|
||||
}
|
||||
|
||||
function formatDaysRemaining(expiresAt: string): string {
|
||||
const now = new Date();
|
||||
const expires = new Date(expiresAt);
|
||||
const diff = expires.getTime() - now.getTime();
|
||||
if (diff < 0) return t('subscriptionProgress.expired');
|
||||
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
||||
if (days === 0) return t('subscriptionProgress.expirestoday');
|
||||
if (days === 1) return t('subscriptionProgress.expiresTomorrow');
|
||||
return t('subscriptionProgress.daysRemaining', { days });
|
||||
const now = new Date()
|
||||
const expires = new Date(expiresAt)
|
||||
const diff = expires.getTime() - now.getTime()
|
||||
if (diff < 0) return t('subscriptionProgress.expired')
|
||||
const days = Math.ceil(diff / (1000 * 60 * 60 * 24))
|
||||
if (days === 0) return t('subscriptionProgress.expirestoday')
|
||||
if (days === 1) return t('subscriptionProgress.expiresTomorrow')
|
||||
return t('subscriptionProgress.daysRemaining', { days })
|
||||
}
|
||||
|
||||
function getDaysRemainingClass(expiresAt: string): string {
|
||||
const now = new Date();
|
||||
const expires = new Date(expiresAt);
|
||||
const diff = expires.getTime() - now.getTime();
|
||||
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
||||
if (days <= 3) return 'text-red-600 dark:text-red-400';
|
||||
if (days <= 7) return 'text-orange-600 dark:text-orange-400';
|
||||
return 'text-gray-500 dark:text-dark-400';
|
||||
const now = new Date()
|
||||
const expires = new Date(expiresAt)
|
||||
const diff = expires.getTime() - now.getTime()
|
||||
const days = Math.ceil(diff / (1000 * 60 * 60 * 24))
|
||||
if (days <= 3) return 'text-red-600 dark:text-red-400'
|
||||
if (days <= 7) return 'text-orange-600 dark:text-orange-400'
|
||||
return 'text-gray-500 dark:text-dark-400'
|
||||
}
|
||||
|
||||
function toggleTooltip() {
|
||||
tooltipOpen.value = !tooltipOpen.value;
|
||||
tooltipOpen.value = !tooltipOpen.value
|
||||
}
|
||||
|
||||
function closeTooltip() {
|
||||
tooltipOpen.value = false;
|
||||
tooltipOpen.value = false
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (containerRef.value && !containerRef.value.contains(event.target as Node)) {
|
||||
closeTooltip();
|
||||
closeTooltip()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSubscriptions() {
|
||||
try {
|
||||
loading.value = true;
|
||||
activeSubscriptions.value = await subscriptionsAPI.getActiveSubscriptions();
|
||||
loading.value = true
|
||||
activeSubscriptions.value = await subscriptionsAPI.getActiveSubscriptions()
|
||||
} catch (error) {
|
||||
console.error('Failed to load subscriptions:', error);
|
||||
activeSubscriptions.value = [];
|
||||
console.error('Failed to load subscriptions:', error)
|
||||
activeSubscriptions.value = []
|
||||
} finally {
|
||||
loading.value = false;
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
loadSubscriptions();
|
||||
});
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
loadSubscriptions()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
});
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
// Refresh subscriptions periodically (every 5 minutes)
|
||||
let refreshInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let refreshInterval: ReturnType<typeof setInterval> | null = null
|
||||
onMounted(() => {
|
||||
refreshInterval = setInterval(loadSubscriptions, 5 * 60 * 1000);
|
||||
});
|
||||
refreshInterval = setInterval(loadSubscriptions, 5 * 60 * 1000)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
clearInterval(refreshInterval)
|
||||
}
|
||||
});
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
class="fixed top-4 right-4 z-[9999] space-y-3 pointer-events-none"
|
||||
class="pointer-events-none fixed right-4 top-4 z-[9999] space-y-3"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
@@ -26,26 +26,25 @@
|
||||
<div class="p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Icon -->
|
||||
<div class="flex-shrink-0 mt-0.5">
|
||||
<div class="mt-0.5 flex-shrink-0">
|
||||
<component
|
||||
:is="getIcon(toast.type)"
|
||||
:class="['w-5 h-5', getIconColor(toast.type)]"
|
||||
:class="['h-5 w-5', getIconColor(toast.type)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
v-if="toast.title"
|
||||
class="text-sm font-semibold text-gray-900 dark:text-white"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p v-if="toast.title" class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ toast.title }}
|
||||
</p>
|
||||
<p
|
||||
:class="[
|
||||
'text-sm leading-relaxed',
|
||||
toast.title ? 'mt-1 text-gray-600 dark:text-gray-300' : 'text-gray-900 dark:text-white'
|
||||
toast.title
|
||||
? 'mt-1 text-gray-600 dark:text-gray-300'
|
||||
: 'text-gray-900 dark:text-white'
|
||||
]"
|
||||
>
|
||||
{{ toast.message }}
|
||||
@@ -55,10 +54,10 @@
|
||||
<!-- Close button -->
|
||||
<button
|
||||
@click="removeToast(toast.id)"
|
||||
class="flex-shrink-0 p-1 -m-1 text-gray-400 dark:text-gray-500 transition-colors rounded hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-dark-700"
|
||||
class="-m-1 flex-shrink-0 rounded p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:text-gray-500 dark:hover:bg-dark-700 dark:hover:text-gray-300"
|
||||
aria-label="Close notification"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
@@ -70,10 +69,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div
|
||||
v-if="toast.duration"
|
||||
class="h-1 bg-gray-100 dark:bg-dark-700"
|
||||
>
|
||||
<div v-if="toast.duration" class="h-1 bg-gray-100 dark:bg-dark-700">
|
||||
<div
|
||||
:class="['h-full transition-all', getProgressBarColor(toast.type)]"
|
||||
:style="{ width: `${getProgress(toast)}%` }"
|
||||
|
||||
@@ -3,33 +3,27 @@
|
||||
type="button"
|
||||
@click="toggle"
|
||||
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 dark:focus:ring-offset-dark-800"
|
||||
:class="[
|
||||
modelValue
|
||||
? 'bg-primary-600'
|
||||
: 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
:class="[modelValue ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600']"
|
||||
role="switch"
|
||||
:aria-checked="modelValue"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
|
||||
:class="[
|
||||
modelValue ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
:class="[modelValue ? 'translate-x-5' : 'translate-x-0']"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
}>();
|
||||
modelValue: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
}>();
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
function toggle() {
|
||||
emit('update:modelValue', !props.modelValue);
|
||||
emit('update:modelValue', !props.modelValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -4,20 +4,25 @@
|
||||
<template v-if="isAdmin">
|
||||
<button
|
||||
@click="toggleDropdown"
|
||||
class="flex items-center gap-1.5 px-2 py-1 text-xs rounded-lg transition-colors"
|
||||
class="flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs transition-colors"
|
||||
:class="[
|
||||
hasUpdate
|
||||
? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 hover:bg-amber-200 dark:hover:bg-amber-900/50'
|
||||
: 'bg-gray-100 dark:bg-dark-800 text-gray-600 dark:text-dark-400 hover:bg-gray-200 dark:hover:bg-dark-700'
|
||||
? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:hover:bg-amber-900/50'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-800 dark:text-dark-400 dark:hover:bg-dark-700'
|
||||
]"
|
||||
:title="hasUpdate ? 'New version available' : 'Up to date'"
|
||||
>
|
||||
<span v-if="currentVersion" class="font-medium">v{{ currentVersion }}</span>
|
||||
<span v-else class="font-medium w-12 h-3 bg-gray-200 dark:bg-dark-600 rounded animate-pulse"></span>
|
||||
<span
|
||||
v-else
|
||||
class="h-3 w-12 animate-pulse rounded bg-gray-200 font-medium dark:bg-dark-600"
|
||||
></span>
|
||||
<!-- Update indicator -->
|
||||
<span v-if="hasUpdate" class="relative flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-amber-500"></span>
|
||||
<span
|
||||
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-amber-400 opacity-75"
|
||||
></span>
|
||||
<span class="relative inline-flex h-2 w-2 rounded-full bg-amber-500"></span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -26,19 +31,34 @@
|
||||
<div
|
||||
v-if="dropdownOpen"
|
||||
ref="dropdownRef"
|
||||
class="absolute left-0 mt-2 w-64 bg-white dark:bg-dark-800 rounded-xl shadow-lg border border-gray-200 dark:border-dark-700 z-50 overflow-hidden"
|
||||
class="absolute left-0 z-50 mt-2 w-64 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg dark:border-dark-700 dark:bg-dark-800"
|
||||
>
|
||||
<!-- Header with refresh button -->
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-dark-700">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-dark-300">{{ t('version.currentVersion') }}</span>
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-gray-100 px-4 py-3 dark:border-dark-700"
|
||||
>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-dark-300">{{
|
||||
t('version.currentVersion')
|
||||
}}</span>
|
||||
<button
|
||||
@click="refreshVersion(true)"
|
||||
class="p-1.5 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-dark-200 hover:bg-gray-100 dark:hover:bg-dark-700 transition-colors"
|
||||
class="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-dark-700 dark:hover:text-dark-200"
|
||||
:disabled="loading"
|
||||
:title="t('version.refresh')"
|
||||
>
|
||||
<svg class="w-4 h-4" :class="{ 'animate-spin': loading }" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
:class="{ 'animate-spin': loading }"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -46,42 +66,90 @@
|
||||
<div class="p-4">
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-6">
|
||||
<svg class="animate-spin h-6 w-6 text-primary-500" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
<svg class="h-6 w-6 animate-spin text-primary-500" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<template v-else>
|
||||
<!-- Version display - centered and prominent -->
|
||||
<div class="text-center mb-4">
|
||||
<div class="mb-4 text-center">
|
||||
<div class="inline-flex items-center gap-2">
|
||||
<span v-if="currentVersion" class="text-2xl font-bold text-gray-900 dark:text-white">v{{ currentVersion }}</span>
|
||||
<span
|
||||
v-if="currentVersion"
|
||||
class="text-2xl font-bold text-gray-900 dark:text-white"
|
||||
>v{{ currentVersion }}</span
|
||||
>
|
||||
<span v-else class="text-2xl font-bold text-gray-400 dark:text-dark-500">--</span>
|
||||
<!-- Show check mark when up to date -->
|
||||
<span v-if="!hasUpdate" class="flex items-center justify-center w-5 h-5 rounded-full bg-green-100 dark:bg-green-900/30">
|
||||
<svg class="w-3 h-3 text-green-600 dark:text-green-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
<span
|
||||
v-if="!hasUpdate"
|
||||
class="flex h-5 w-5 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30"
|
||||
>
|
||||
<svg
|
||||
class="h-3 w-3 text-green-600 dark:text-green-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400 mt-1">
|
||||
{{ hasUpdate ? t('version.latestVersion') + ': v' + latestVersion : t('version.upToDate') }}
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-dark-400">
|
||||
{{
|
||||
hasUpdate
|
||||
? t('version.latestVersion') + ': v' + latestVersion
|
||||
: t('version.upToDate')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Priority 1: Update error (must check before hasUpdate) -->
|
||||
<div v-if="updateError" class="space-y-2">
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800/50">
|
||||
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-red-100 dark:bg-red-900/50 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-800/50 dark:bg-red-900/20"
|
||||
>
|
||||
<div
|
||||
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/50"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 text-red-600 dark:text-red-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-red-700 dark:text-red-300">{{ t('version.updateFailed') }}</p>
|
||||
<p class="text-xs text-red-600/70 dark:text-red-400/70 truncate">{{ updateError }}</p>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-red-700 dark:text-red-300">
|
||||
{{ t('version.updateFailed') }}
|
||||
</p>
|
||||
<p class="truncate text-xs text-red-600/70 dark:text-red-400/70">
|
||||
{{ updateError }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -89,7 +157,7 @@
|
||||
<button
|
||||
@click="handleUpdate"
|
||||
:disabled="updating"
|
||||
class="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-500 hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
class="flex w-full items-center justify-center gap-2 rounded-lg bg-red-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{{ t('version.retry') }}
|
||||
</button>
|
||||
@@ -97,15 +165,29 @@
|
||||
|
||||
<!-- Priority 2: Update success - need restart -->
|
||||
<div v-else-if="updateSuccess && needRestart" class="space-y-2">
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800/50">
|
||||
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-green-100 dark:bg-green-900/50 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-green-600 dark:text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-lg border border-green-200 bg-green-50 p-3 dark:border-green-800/50 dark:bg-green-900/20"
|
||||
>
|
||||
<div
|
||||
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/50"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 text-green-600 dark:text-green-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-green-700 dark:text-green-300">{{ t('version.updateComplete') }}</p>
|
||||
<p class="text-xs text-green-600/70 dark:text-green-400/70">{{ t('version.restartRequired') }}</p>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-green-700 dark:text-green-300">
|
||||
{{ t('version.updateComplete') }}
|
||||
</p>
|
||||
<p class="text-xs text-green-600/70 dark:text-green-400/70">
|
||||
{{ t('version.restartRequired') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -113,18 +195,47 @@
|
||||
<button
|
||||
@click="handleRestart"
|
||||
:disabled="restarting"
|
||||
class="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white bg-green-500 hover:bg-green-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
class="flex w-full items-center justify-center gap-2 rounded-lg bg-green-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-green-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<svg v-if="restarting" class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
<svg
|
||||
v-if="restarting"
|
||||
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 v-else class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" 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
|
||||
v-else
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M4 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>
|
||||
<template v-if="restarting">
|
||||
<span>{{ t('version.restarting') }}</span>
|
||||
<span v-if="restartCountdown > 0" class="tabular-nums">({{ restartCountdown }}s)</span>
|
||||
<span v-if="restartCountdown > 0" class="tabular-nums"
|
||||
>({{ restartCountdown }}s)</span
|
||||
>
|
||||
</template>
|
||||
<span v-else>{{ t('version.restartNow') }}</span>
|
||||
</button>
|
||||
@@ -137,42 +248,96 @@
|
||||
:href="releaseInfo.html_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-3 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50 hover:bg-amber-100 dark:hover:bg-amber-900/30 transition-colors group"
|
||||
class="group flex items-center gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3 transition-colors hover:bg-amber-100 dark:border-amber-800/50 dark:bg-amber-900/20 dark:hover:bg-amber-900/30"
|
||||
>
|
||||
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
<div
|
||||
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/50"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 text-amber-600 dark:text-amber-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-amber-700 dark:text-amber-300">{{ t('version.updateAvailable') }}</p>
|
||||
<p class="text-xs text-amber-600/70 dark:text-amber-400/70">v{{ latestVersion }}</p>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-amber-700 dark:text-amber-300">
|
||||
{{ t('version.updateAvailable') }}
|
||||
</p>
|
||||
<p class="text-xs text-amber-600/70 dark:text-amber-400/70">
|
||||
v{{ latestVersion }}
|
||||
</p>
|
||||
</div>
|
||||
<svg class="w-4 h-4 text-amber-500 dark:text-amber-400 group-hover:translate-x-0.5 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<svg
|
||||
class="h-4 w-4 text-amber-500 transition-transform group-hover:translate-x-0.5 dark:text-amber-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
<!-- Source build hint -->
|
||||
<div class="flex items-center gap-2 p-2 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50">
|
||||
<svg class="w-3.5 h-3.5 text-blue-500 dark:text-blue-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 p-2 dark:border-blue-800/50 dark:bg-blue-900/20"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5 flex-shrink-0 text-blue-500 dark:text-blue-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-xs text-blue-600 dark:text-blue-400">{{ t('version.sourceModeHint') }}</p>
|
||||
<p class="text-xs text-blue-600 dark:text-blue-400">
|
||||
{{ t('version.sourceModeHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Priority 4: Update available for release build - show update button -->
|
||||
<div v-else-if="hasUpdate && isReleaseBuild" class="space-y-2">
|
||||
<!-- Update info card -->
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50">
|
||||
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-800/50 dark:bg-amber-900/20"
|
||||
>
|
||||
<div
|
||||
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/50"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 text-amber-600 dark:text-amber-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-amber-700 dark:text-amber-300">{{ t('version.updateAvailable') }}</p>
|
||||
<p class="text-xs text-amber-600/70 dark:text-amber-400/70">v{{ latestVersion }}</p>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-amber-700 dark:text-amber-300">
|
||||
{{ t('version.updateAvailable') }}
|
||||
</p>
|
||||
<p class="text-xs text-amber-600/70 dark:text-amber-400/70">
|
||||
v{{ latestVersion }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -180,14 +345,36 @@
|
||||
<button
|
||||
@click="handleUpdate"
|
||||
:disabled="updating"
|
||||
class="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white bg-primary-500 hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
class="flex w-full items-center justify-center gap-2 rounded-lg bg-primary-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<svg v-if="updating" class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
<svg v-if="updating" 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 v-else class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
<svg
|
||||
v-else
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
{{ updating ? t('version.updating') : t('version.updateNow') }}
|
||||
</button>
|
||||
@@ -198,11 +385,21 @@
|
||||
:href="releaseInfo.html_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center justify-center gap-1 text-xs text-gray-500 dark:text-dark-400 hover:text-gray-700 dark:hover:text-dark-200 transition-colors"
|
||||
class="flex items-center justify-center gap-1 text-xs text-gray-500 transition-colors hover:text-gray-700 dark:text-dark-400 dark:hover:text-dark-200"
|
||||
>
|
||||
{{ t('version.viewChangelog') }}
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
<svg
|
||||
class="h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
@@ -213,10 +410,14 @@
|
||||
:href="releaseInfo.html_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center justify-center gap-2 py-2 text-sm text-gray-500 dark:text-dark-400 hover:text-gray-700 dark:hover:text-dark-200 transition-colors"
|
||||
class="flex items-center justify-center gap-2 py-2 text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-dark-400 dark:hover:text-dark-200"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.464-1.11-1.464-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z" />
|
||||
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.464-1.11-1.464-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('version.viewRelease') }}
|
||||
</a>
|
||||
@@ -227,166 +428,163 @@
|
||||
</template>
|
||||
|
||||
<!-- Non-admin: Simple static version text -->
|
||||
<span
|
||||
v-else-if="version"
|
||||
class="text-xs text-gray-500 dark:text-dark-400"
|
||||
>
|
||||
<span v-else-if="version" class="text-xs text-gray-500 dark:text-dark-400">
|
||||
v{{ version }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore, useAppStore } from '@/stores';
|
||||
import { performUpdate, restartService } from '@/api/admin/system';
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
import { performUpdate, restartService } from '@/api/admin/system'
|
||||
|
||||
const { t } = useI18n();
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
version?: string;
|
||||
}>();
|
||||
version?: string
|
||||
}>()
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const appStore = useAppStore();
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const isAdmin = computed(() => authStore.isAdmin);
|
||||
const isAdmin = computed(() => authStore.isAdmin)
|
||||
|
||||
const dropdownOpen = ref(false);
|
||||
const dropdownRef = ref<HTMLElement | null>(null);
|
||||
const dropdownOpen = ref(false)
|
||||
const dropdownRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// Use store's cached version state
|
||||
const loading = computed(() => appStore.versionLoading);
|
||||
const currentVersion = computed(() => appStore.currentVersion || props.version || '');
|
||||
const latestVersion = computed(() => appStore.latestVersion);
|
||||
const hasUpdate = computed(() => appStore.hasUpdate);
|
||||
const releaseInfo = computed(() => appStore.releaseInfo);
|
||||
const buildType = computed(() => appStore.buildType);
|
||||
const loading = computed(() => appStore.versionLoading)
|
||||
const currentVersion = computed(() => appStore.currentVersion || props.version || '')
|
||||
const latestVersion = computed(() => appStore.latestVersion)
|
||||
const hasUpdate = computed(() => appStore.hasUpdate)
|
||||
const releaseInfo = computed(() => appStore.releaseInfo)
|
||||
const buildType = computed(() => appStore.buildType)
|
||||
|
||||
// Update process states (local to this component)
|
||||
const updating = ref(false);
|
||||
const restarting = ref(false);
|
||||
const needRestart = ref(false);
|
||||
const updateError = ref('');
|
||||
const updateSuccess = ref(false);
|
||||
const restartCountdown = ref(0);
|
||||
const updating = ref(false)
|
||||
const restarting = ref(false)
|
||||
const needRestart = ref(false)
|
||||
const updateError = ref('')
|
||||
const updateSuccess = ref(false)
|
||||
const restartCountdown = ref(0)
|
||||
|
||||
// Only show update check for release builds (binary/docker deployment)
|
||||
const isReleaseBuild = computed(() => buildType.value === 'release');
|
||||
const isReleaseBuild = computed(() => buildType.value === 'release')
|
||||
|
||||
function toggleDropdown() {
|
||||
dropdownOpen.value = !dropdownOpen.value;
|
||||
dropdownOpen.value = !dropdownOpen.value
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
dropdownOpen.value = false;
|
||||
dropdownOpen.value = false
|
||||
}
|
||||
|
||||
async function refreshVersion(force = true) {
|
||||
if (!isAdmin.value) return;
|
||||
if (!isAdmin.value) return
|
||||
|
||||
// Reset update states when refreshing
|
||||
updateError.value = '';
|
||||
updateSuccess.value = false;
|
||||
needRestart.value = false;
|
||||
updateError.value = ''
|
||||
updateSuccess.value = false
|
||||
needRestart.value = false
|
||||
|
||||
await appStore.fetchVersion(force);
|
||||
await appStore.fetchVersion(force)
|
||||
}
|
||||
|
||||
async function handleUpdate() {
|
||||
if (updating.value) return;
|
||||
if (updating.value) return
|
||||
|
||||
updating.value = true;
|
||||
updateError.value = '';
|
||||
updateSuccess.value = false;
|
||||
updating.value = true
|
||||
updateError.value = ''
|
||||
updateSuccess.value = false
|
||||
|
||||
try {
|
||||
const result = await performUpdate();
|
||||
updateSuccess.value = true;
|
||||
needRestart.value = result.need_restart;
|
||||
const result = await performUpdate()
|
||||
updateSuccess.value = true
|
||||
needRestart.value = result.need_restart
|
||||
// Clear version cache to reflect update completed
|
||||
appStore.clearVersionCache();
|
||||
appStore.clearVersionCache()
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { message?: string } }; message?: string };
|
||||
updateError.value = err.response?.data?.message || err.message || t('version.updateFailed');
|
||||
const err = error as { response?: { data?: { message?: string } }; message?: string }
|
||||
updateError.value = err.response?.data?.message || err.message || t('version.updateFailed')
|
||||
} finally {
|
||||
updating.value = false;
|
||||
updating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRestart() {
|
||||
if (restarting.value) return;
|
||||
if (restarting.value) return
|
||||
|
||||
restarting.value = true;
|
||||
restartCountdown.value = 8;
|
||||
restarting.value = true
|
||||
restartCountdown.value = 8
|
||||
|
||||
try {
|
||||
await restartService();
|
||||
await restartService()
|
||||
// Service will restart, page will reload automatically or show disconnected
|
||||
} catch (error) {
|
||||
// Expected - connection will be lost during restart
|
||||
console.log('Service restarting...');
|
||||
console.log('Service restarting...')
|
||||
}
|
||||
|
||||
// Start countdown
|
||||
const countdownInterval = setInterval(() => {
|
||||
restartCountdown.value--;
|
||||
restartCountdown.value--
|
||||
if (restartCountdown.value <= 0) {
|
||||
clearInterval(countdownInterval);
|
||||
clearInterval(countdownInterval)
|
||||
// Try to check if service is back before reload
|
||||
checkServiceAndReload();
|
||||
checkServiceAndReload()
|
||||
}
|
||||
}, 1000);
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function checkServiceAndReload() {
|
||||
const maxRetries = 5;
|
||||
const retryDelay = 1000;
|
||||
const maxRetries = 5
|
||||
const retryDelay = 1000
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
const response = await fetch('/api/health', {
|
||||
method: 'GET',
|
||||
cache: 'no-cache'
|
||||
});
|
||||
})
|
||||
if (response.ok) {
|
||||
// Service is back, reload page
|
||||
window.location.reload();
|
||||
return;
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Service not ready yet
|
||||
}
|
||||
|
||||
if (i < maxRetries - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
||||
await new Promise((resolve) => setTimeout(resolve, retryDelay))
|
||||
}
|
||||
}
|
||||
|
||||
// After retries, reload anyway
|
||||
window.location.reload();
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as Node;
|
||||
const button = (event.target as Element).closest('button');
|
||||
const target = event.target as Node
|
||||
const button = (event.target as Element).closest('button')
|
||||
if (dropdownRef.value && !dropdownRef.value.contains(target) && !button?.contains(target)) {
|
||||
closeDropdown();
|
||||
closeDropdown()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isAdmin.value) {
|
||||
// Use cached version if available, otherwise fetch
|
||||
appStore.fetchVersion(false);
|
||||
appStore.fetchVersion(false)
|
||||
}
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
});
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
});
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
<template>
|
||||
<header class="sticky top-0 z-30 glass border-b border-gray-200/50 dark:border-dark-700/50">
|
||||
<div class="flex items-center justify-between h-16 px-4 md:px-6">
|
||||
<header class="glass sticky top-0 z-30 border-b border-gray-200/50 dark:border-dark-700/50">
|
||||
<div class="flex h-16 items-center justify-between px-4 md:px-6">
|
||||
<!-- Left: Mobile Menu Toggle + Page Title -->
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
@click="toggleMobileSidebar"
|
||||
class="lg:hidden btn-ghost btn-icon"
|
||||
class="btn-ghost btn-icon lg:hidden"
|
||||
aria-label="Toggle Menu"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@@ -32,9 +42,22 @@
|
||||
<SubscriptionProgressMini v-if="user" />
|
||||
|
||||
<!-- Balance Display -->
|
||||
<div v-if="user" class="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-xl bg-primary-50 dark:bg-primary-900/20">
|
||||
<svg class="w-4 h-4 text-primary-600 dark:text-primary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
|
||||
<div
|
||||
v-if="user"
|
||||
class="hidden items-center gap-2 rounded-xl bg-primary-50 px-3 py-1.5 dark:bg-primary-900/20 sm:flex"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 text-primary-600 dark:text-primary-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-sm font-semibold text-primary-700 dark:text-primary-300">
|
||||
${{ user.balance?.toFixed(2) || '0.00' }}
|
||||
@@ -45,64 +68,89 @@
|
||||
<div v-if="user" class="relative" ref="dropdownRef">
|
||||
<button
|
||||
@click="toggleDropdown"
|
||||
class="flex items-center gap-2 p-1.5 rounded-xl hover:bg-gray-100 dark:hover:bg-dark-800 transition-colors"
|
||||
class="flex items-center gap-2 rounded-xl p-1.5 transition-colors hover:bg-gray-100 dark:hover:bg-dark-800"
|
||||
aria-label="User Menu"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-xl bg-gradient-to-br from-primary-500 to-primary-600 text-white flex items-center justify-center text-sm font-medium shadow-sm">
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-xl bg-gradient-to-br from-primary-500 to-primary-600 text-sm font-medium text-white shadow-sm"
|
||||
>
|
||||
{{ userInitials }}
|
||||
</div>
|
||||
<div class="hidden md:block text-left">
|
||||
<div class="hidden text-left md:block">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ displayName }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-dark-400 capitalize">
|
||||
<div class="text-xs capitalize text-gray-500 dark:text-dark-400">
|
||||
{{ user.role }}
|
||||
</div>
|
||||
</div>
|
||||
<svg class="w-4 h-4 text-gray-400 hidden md:block" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
<svg
|
||||
class="hidden h-4 w-4 text-gray-400 md:block"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Dropdown Menu -->
|
||||
<transition name="dropdown">
|
||||
<div
|
||||
v-if="dropdownOpen"
|
||||
class="dropdown right-0 mt-2 w-56"
|
||||
>
|
||||
<div v-if="dropdownOpen" class="dropdown right-0 mt-2 w-56">
|
||||
<!-- User Info -->
|
||||
<div class="px-4 py-3 border-b border-gray-100 dark:border-dark-700">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">{{ displayName }}</div>
|
||||
<div class="border-b border-gray-100 px-4 py-3 dark:border-dark-700">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ displayName }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-dark-400">{{ user.email }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Balance (mobile only) -->
|
||||
<div class="sm:hidden px-4 py-2 border-b border-gray-100 dark:border-dark-700">
|
||||
<div class="text-xs text-gray-500 dark:text-dark-400">{{ t('common.balance') }}</div>
|
||||
<div class="border-b border-gray-100 px-4 py-2 dark:border-dark-700 sm:hidden">
|
||||
<div class="text-xs text-gray-500 dark:text-dark-400">
|
||||
{{ t('common.balance') }}
|
||||
</div>
|
||||
<div class="text-sm font-semibold text-primary-600 dark:text-primary-400">
|
||||
${{ user.balance?.toFixed(2) || '0.00' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-1">
|
||||
<router-link
|
||||
to="/profile"
|
||||
@click="closeDropdown"
|
||||
class="dropdown-item"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||
<router-link to="/profile" @click="closeDropdown" class="dropdown-item">
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('nav.profile') }}
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/keys"
|
||||
@click="closeDropdown"
|
||||
class="dropdown-item"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
|
||||
<router-link to="/keys" @click="closeDropdown" class="dropdown-item">
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('nav.apiKeys') }}
|
||||
</router-link>
|
||||
@@ -114,31 +162,60 @@
|
||||
@click="closeDropdown"
|
||||
class="dropdown-item"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.464-1.11-1.464-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z" />
|
||||
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.464-1.11-1.464-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('nav.github') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Contact Support (only show if configured) -->
|
||||
<div v-if="contactInfo" class="border-t border-gray-100 dark:border-dark-700 px-4 py-2.5">
|
||||
<div
|
||||
v-if="contactInfo"
|
||||
class="border-t border-gray-100 px-4 py-2.5 dark:border-dark-700"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 01-.825-.242m9.345-8.334a2.126 2.126 0 00-.476-.095 48.64 48.64 0 00-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0011.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
|
||||
<svg
|
||||
class="h-3.5 w-3.5 flex-shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 01-.825-.242m9.345-8.334a2.126 2.126 0 00-.476-.095 48.64 48.64 0 00-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0011.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155"
|
||||
/>
|
||||
</svg>
|
||||
<span>{{ t('common.contactSupport') }}:</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ contactInfo }}</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{
|
||||
contactInfo
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-100 dark:border-dark-700 py-1">
|
||||
<div class="border-t border-gray-100 py-1 dark:border-dark-700">
|
||||
<button
|
||||
@click="handleLogout"
|
||||
class="dropdown-item w-full text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
class="dropdown-item w-full text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('nav.logout') }}
|
||||
</button>
|
||||
@@ -152,90 +229,90 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAppStore, useAuthStore } from '@/stores';
|
||||
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue';
|
||||
import SubscriptionProgressMini from '@/components/common/SubscriptionProgressMini.vue';
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore, useAuthStore } from '@/stores'
|
||||
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
|
||||
import SubscriptionProgressMini from '@/components/common/SubscriptionProgressMini.vue'
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const appStore = useAppStore();
|
||||
const authStore = useAuthStore();
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const user = computed(() => authStore.user);
|
||||
const dropdownOpen = ref(false);
|
||||
const dropdownRef = ref<HTMLElement | null>(null);
|
||||
const contactInfo = computed(() => appStore.contactInfo);
|
||||
const user = computed(() => authStore.user)
|
||||
const dropdownOpen = ref(false)
|
||||
const dropdownRef = ref<HTMLElement | null>(null)
|
||||
const contactInfo = computed(() => appStore.contactInfo)
|
||||
|
||||
const userInitials = computed(() => {
|
||||
if (!user.value) return '';
|
||||
if (!user.value) return ''
|
||||
// Prefer username, fallback to email
|
||||
if (user.value.username) {
|
||||
return user.value.username.substring(0, 2).toUpperCase();
|
||||
return user.value.username.substring(0, 2).toUpperCase()
|
||||
}
|
||||
if (user.value.email) {
|
||||
// Get the part before @ and take first 2 chars
|
||||
const localPart = user.value.email.split('@')[0];
|
||||
return localPart.substring(0, 2).toUpperCase();
|
||||
const localPart = user.value.email.split('@')[0]
|
||||
return localPart.substring(0, 2).toUpperCase()
|
||||
}
|
||||
return '';
|
||||
});
|
||||
return ''
|
||||
})
|
||||
|
||||
const displayName = computed(() => {
|
||||
if (!user.value) return '';
|
||||
return user.value.username || user.value.email?.split('@')[0] || '';
|
||||
});
|
||||
if (!user.value) return ''
|
||||
return user.value.username || user.value.email?.split('@')[0] || ''
|
||||
})
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
const titleKey = route.meta.titleKey as string;
|
||||
const titleKey = route.meta.titleKey as string
|
||||
if (titleKey) {
|
||||
return t(titleKey);
|
||||
return t(titleKey)
|
||||
}
|
||||
return (route.meta.title as string) || '';
|
||||
});
|
||||
return (route.meta.title as string) || ''
|
||||
})
|
||||
|
||||
const pageDescription = computed(() => {
|
||||
const descKey = route.meta.descriptionKey as string;
|
||||
const descKey = route.meta.descriptionKey as string
|
||||
if (descKey) {
|
||||
return t(descKey);
|
||||
return t(descKey)
|
||||
}
|
||||
return (route.meta.description as string) || '';
|
||||
});
|
||||
return (route.meta.description as string) || ''
|
||||
})
|
||||
|
||||
function toggleMobileSidebar() {
|
||||
appStore.toggleMobileSidebar();
|
||||
appStore.toggleMobileSidebar()
|
||||
}
|
||||
|
||||
function toggleDropdown() {
|
||||
dropdownOpen.value = !dropdownOpen.value;
|
||||
dropdownOpen.value = !dropdownOpen.value
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
dropdownOpen.value = false;
|
||||
dropdownOpen.value = false
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
closeDropdown();
|
||||
authStore.logout();
|
||||
await router.push('/login');
|
||||
closeDropdown()
|
||||
authStore.logout()
|
||||
await router.push('/login')
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) {
|
||||
closeDropdown();
|
||||
closeDropdown()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
});
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
});
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 dark:bg-dark-950">
|
||||
<!-- Background Decoration -->
|
||||
<div class="fixed inset-0 bg-mesh-gradient pointer-events-none"></div>
|
||||
<div class="pointer-events-none fixed inset-0 bg-mesh-gradient"></div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<AppSidebar />
|
||||
@@ -9,9 +9,7 @@
|
||||
<!-- Main Content Area -->
|
||||
<div
|
||||
class="relative min-h-screen transition-all duration-300"
|
||||
:class="[
|
||||
sidebarCollapsed ? 'lg:ml-[72px]' : 'lg:ml-64',
|
||||
]"
|
||||
:class="[sidebarCollapsed ? 'lg:ml-[72px]' : 'lg:ml-64']"
|
||||
>
|
||||
<!-- Header -->
|
||||
<AppHeader />
|
||||
@@ -25,12 +23,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useAppStore } from '@/stores';
|
||||
import AppSidebar from './AppSidebar.vue';
|
||||
import AppHeader from './AppHeader.vue';
|
||||
import { computed } from 'vue'
|
||||
import { useAppStore } from '@/stores'
|
||||
import AppSidebar from './AppSidebar.vue'
|
||||
import AppHeader from './AppHeader.vue'
|
||||
|
||||
const appStore = useAppStore();
|
||||
const sidebarCollapsed = computed(() => appStore.sidebarCollapsed);
|
||||
const appStore = useAppStore()
|
||||
const sidebarCollapsed = computed(() => appStore.sidebarCollapsed)
|
||||
</script>
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
<!-- Logo/Brand -->
|
||||
<div class="sidebar-header">
|
||||
<!-- Custom Logo or Default Logo -->
|
||||
<div class="w-9 h-9 rounded-xl overflow-hidden flex items-center justify-center shadow-glow">
|
||||
<img :src="siteLogo || '/logo.png'" alt="Logo" class="w-full h-full object-contain" />
|
||||
<div class="flex h-9 w-9 items-center justify-center overflow-hidden rounded-xl shadow-glow">
|
||||
<img :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<div v-if="!sidebarCollapsed" class="flex flex-col">
|
||||
@@ -38,7 +38,7 @@
|
||||
:title="sidebarCollapsed ? item.label : undefined"
|
||||
@click="handleMenuItemClick"
|
||||
>
|
||||
<component :is="item.icon" class="w-5 h-5 flex-shrink-0" />
|
||||
<component :is="item.icon" class="h-5 w-5 flex-shrink-0" />
|
||||
<transition name="fade">
|
||||
<span v-if="!sidebarCollapsed">{{ item.label }}</span>
|
||||
</transition>
|
||||
@@ -50,7 +50,7 @@
|
||||
<div v-if="!sidebarCollapsed" class="sidebar-section-title">
|
||||
{{ t('nav.myAccount') }}
|
||||
</div>
|
||||
<div v-else class="h-px bg-gray-200 dark:bg-dark-700 mx-3 my-3"></div>
|
||||
<div v-else class="mx-3 my-3 h-px bg-gray-200 dark:bg-dark-700"></div>
|
||||
|
||||
<router-link
|
||||
v-for="item in personalNavItems"
|
||||
@@ -61,7 +61,7 @@
|
||||
:title="sidebarCollapsed ? item.label : undefined"
|
||||
@click="handleMenuItemClick"
|
||||
>
|
||||
<component :is="item.icon" class="w-5 h-5 flex-shrink-0" />
|
||||
<component :is="item.icon" class="h-5 w-5 flex-shrink-0" />
|
||||
<transition name="fade">
|
||||
<span v-if="!sidebarCollapsed">{{ item.label }}</span>
|
||||
</transition>
|
||||
@@ -81,7 +81,7 @@
|
||||
:title="sidebarCollapsed ? item.label : undefined"
|
||||
@click="handleMenuItemClick"
|
||||
>
|
||||
<component :is="item.icon" class="w-5 h-5 flex-shrink-0" />
|
||||
<component :is="item.icon" class="h-5 w-5 flex-shrink-0" />
|
||||
<transition name="fade">
|
||||
<span v-if="!sidebarCollapsed">{{ item.label }}</span>
|
||||
</transition>
|
||||
@@ -91,17 +91,19 @@
|
||||
</nav>
|
||||
|
||||
<!-- Bottom Section -->
|
||||
<div class="mt-auto border-t border-gray-100 dark:border-dark-800 p-3">
|
||||
<div class="mt-auto border-t border-gray-100 p-3 dark:border-dark-800">
|
||||
<!-- Theme Toggle -->
|
||||
<button
|
||||
@click="toggleTheme"
|
||||
class="sidebar-link w-full mb-2"
|
||||
class="sidebar-link mb-2 w-full"
|
||||
:title="sidebarCollapsed ? (isDark ? t('nav.lightMode') : t('nav.darkMode')) : undefined"
|
||||
>
|
||||
<SunIcon v-if="isDark" class="w-5 h-5 flex-shrink-0 text-amber-500" />
|
||||
<MoonIcon v-else class="w-5 h-5 flex-shrink-0" />
|
||||
<SunIcon v-if="isDark" class="h-5 w-5 flex-shrink-0 text-amber-500" />
|
||||
<MoonIcon v-else class="h-5 w-5 flex-shrink-0" />
|
||||
<transition name="fade">
|
||||
<span v-if="!sidebarCollapsed">{{ isDark ? t('nav.lightMode') : t('nav.darkMode') }}</span>
|
||||
<span v-if="!sidebarCollapsed">{{
|
||||
isDark ? t('nav.lightMode') : t('nav.darkMode')
|
||||
}}</span>
|
||||
</transition>
|
||||
</button>
|
||||
|
||||
@@ -111,8 +113,8 @@
|
||||
class="sidebar-link w-full"
|
||||
:title="sidebarCollapsed ? t('nav.expand') : t('nav.collapse')"
|
||||
>
|
||||
<ChevronDoubleLeftIcon v-if="!sidebarCollapsed" class="w-5 h-5 flex-shrink-0" />
|
||||
<ChevronDoubleRightIcon v-else class="w-5 h-5 flex-shrink-0" />
|
||||
<ChevronDoubleLeftIcon v-if="!sidebarCollapsed" class="h-5 w-5 flex-shrink-0" />
|
||||
<ChevronDoubleRightIcon v-else class="h-5 w-5 flex-shrink-0" />
|
||||
<transition name="fade">
|
||||
<span v-if="!sidebarCollapsed">{{ t('nav.collapse') }}</span>
|
||||
</transition>
|
||||
@@ -124,132 +126,280 @@
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="mobileOpen"
|
||||
class="fixed inset-0 bg-black/50 z-30 lg:hidden"
|
||||
class="fixed inset-0 z-30 bg-black/50 lg:hidden"
|
||||
@click="closeMobile"
|
||||
></div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAppStore, useAuthStore } from '@/stores';
|
||||
import VersionBadge from '@/components/common/VersionBadge.vue';
|
||||
import { computed, h, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore, useAuthStore } from '@/stores'
|
||||
import VersionBadge from '@/components/common/VersionBadge.vue'
|
||||
|
||||
const { t } = useI18n();
|
||||
const { t } = useI18n()
|
||||
|
||||
const route = useRoute();
|
||||
const appStore = useAppStore();
|
||||
const authStore = useAuthStore();
|
||||
const route = useRoute()
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const sidebarCollapsed = computed(() => appStore.sidebarCollapsed);
|
||||
const mobileOpen = computed(() => appStore.mobileOpen);
|
||||
const isAdmin = computed(() => authStore.isAdmin);
|
||||
const isDark = ref(document.documentElement.classList.contains('dark'));
|
||||
const sidebarCollapsed = computed(() => appStore.sidebarCollapsed)
|
||||
const mobileOpen = computed(() => appStore.mobileOpen)
|
||||
const isAdmin = computed(() => authStore.isAdmin)
|
||||
const isDark = ref(document.documentElement.classList.contains('dark'))
|
||||
|
||||
// Site settings from appStore (cached, no flicker)
|
||||
const siteName = computed(() => appStore.siteName);
|
||||
const siteLogo = computed(() => appStore.siteLogo);
|
||||
const siteVersion = computed(() => appStore.siteVersion);
|
||||
const siteName = computed(() => appStore.siteName)
|
||||
const siteLogo = computed(() => appStore.siteLogo)
|
||||
const siteVersion = computed(() => appStore.siteVersion)
|
||||
|
||||
// SVG Icon Components
|
||||
const DashboardIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z' })
|
||||
])
|
||||
};
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const KeyIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z' })
|
||||
])
|
||||
};
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const ChartIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z' })
|
||||
])
|
||||
};
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const GiftIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z' })
|
||||
])
|
||||
};
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const UserIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z' })
|
||||
])
|
||||
};
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const UsersIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z' })
|
||||
])
|
||||
};
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const FolderIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z' })
|
||||
])
|
||||
};
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const CreditCardIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z' })
|
||||
])
|
||||
};
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const GlobeIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418' })
|
||||
])
|
||||
};
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const ServerIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z' })
|
||||
])
|
||||
};
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const TicketIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z' })
|
||||
])
|
||||
};
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const CogIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z' }),
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z' })
|
||||
])
|
||||
};
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z'
|
||||
}),
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const SunIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z' })
|
||||
])
|
||||
};
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const MoonIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z' })
|
||||
])
|
||||
};
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const ChevronDoubleLeftIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'm18.75 4.5-7.5 7.5 7.5 7.5m-6-15L5.25 12l7.5 7.5' })
|
||||
])
|
||||
};
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'm18.75 4.5-7.5 7.5 7.5 7.5m-6-15L5.25 12l7.5 7.5'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const ChevronDoubleRightIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'm5.25 4.5 7.5 7.5-7.5 7.5m6-15 7.5 7.5-7.5 7.5' })
|
||||
])
|
||||
};
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'm5.25 4.5 7.5 7.5-7.5 7.5m6-15 7.5 7.5-7.5 7.5'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
// User navigation items (for regular users)
|
||||
const userNavItems = computed(() => [
|
||||
@@ -258,8 +408,8 @@ const userNavItems = computed(() => [
|
||||
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon },
|
||||
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon },
|
||||
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon },
|
||||
{ path: '/profile', label: t('nav.profile'), icon: UserIcon },
|
||||
]);
|
||||
{ path: '/profile', label: t('nav.profile'), icon: UserIcon }
|
||||
])
|
||||
|
||||
// Personal navigation items (for admin's "My Account" section, without Dashboard)
|
||||
const personalNavItems = computed(() => [
|
||||
@@ -267,8 +417,8 @@ const personalNavItems = computed(() => [
|
||||
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon },
|
||||
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon },
|
||||
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon },
|
||||
{ path: '/profile', label: t('nav.profile'), icon: UserIcon },
|
||||
]);
|
||||
{ path: '/profile', label: t('nav.profile'), icon: UserIcon }
|
||||
])
|
||||
|
||||
// Admin navigation items
|
||||
const adminNavItems = computed(() => [
|
||||
@@ -280,40 +430,43 @@ const adminNavItems = computed(() => [
|
||||
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
|
||||
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon },
|
||||
{ path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon },
|
||||
{ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon },
|
||||
]);
|
||||
{ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon }
|
||||
])
|
||||
|
||||
function toggleSidebar() {
|
||||
appStore.toggleSidebar();
|
||||
appStore.toggleSidebar()
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
isDark.value = !isDark.value;
|
||||
document.documentElement.classList.toggle('dark', isDark.value);
|
||||
localStorage.setItem('theme', isDark.value ? 'dark' : 'light');
|
||||
isDark.value = !isDark.value
|
||||
document.documentElement.classList.toggle('dark', isDark.value)
|
||||
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
function closeMobile() {
|
||||
appStore.setMobileOpen(false);
|
||||
appStore.setMobileOpen(false)
|
||||
}
|
||||
|
||||
function handleMenuItemClick() {
|
||||
if (mobileOpen.value) {
|
||||
setTimeout(() => {
|
||||
appStore.setMobileOpen(false);
|
||||
}, 150);
|
||||
appStore.setMobileOpen(false)
|
||||
}, 150)
|
||||
}
|
||||
}
|
||||
|
||||
function isActive(path: string): boolean {
|
||||
return route.path === path || route.path.startsWith(path + '/');
|
||||
return route.path === path || route.path.startsWith(path + '/')
|
||||
}
|
||||
|
||||
// Initialize theme
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
isDark.value = true;
|
||||
document.documentElement.classList.add('dark');
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
if (
|
||||
savedTheme === 'dark' ||
|
||||
(!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
) {
|
||||
isDark.value = true
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,28 +1,40 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center p-4 relative overflow-hidden">
|
||||
<div class="relative flex min-h-screen items-center justify-center overflow-hidden p-4">
|
||||
<!-- Background -->
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-gray-50 via-primary-50/30 to-gray-100 dark:from-dark-950 dark:via-dark-900 dark:to-dark-950"></div>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-br from-gray-50 via-primary-50/30 to-gray-100 dark:from-dark-950 dark:via-dark-900 dark:to-dark-950"
|
||||
></div>
|
||||
|
||||
<!-- Decorative Elements -->
|
||||
<div class="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div class="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<!-- Gradient Orbs -->
|
||||
<div class="absolute -top-40 -right-40 w-80 h-80 bg-primary-400/20 rounded-full blur-3xl"></div>
|
||||
<div class="absolute -bottom-40 -left-40 w-80 h-80 bg-primary-500/15 rounded-full blur-3xl"></div>
|
||||
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-primary-300/10 rounded-full blur-3xl"></div>
|
||||
<div
|
||||
class="absolute -right-40 -top-40 h-80 w-80 rounded-full bg-primary-400/20 blur-3xl"
|
||||
></div>
|
||||
<div
|
||||
class="absolute -bottom-40 -left-40 h-80 w-80 rounded-full bg-primary-500/15 blur-3xl"
|
||||
></div>
|
||||
<div
|
||||
class="absolute left-1/2 top-1/2 h-96 w-96 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-300/10 blur-3xl"
|
||||
></div>
|
||||
|
||||
<!-- Grid Pattern -->
|
||||
<div class="absolute inset-0 bg-[linear-gradient(rgba(20,184,166,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(20,184,166,0.03)_1px,transparent_1px)] bg-[size:64px_64px]"></div>
|
||||
<div
|
||||
class="absolute inset-0 bg-[linear-gradient(rgba(20,184,166,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(20,184,166,0.03)_1px,transparent_1px)] bg-[size:64px_64px]"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Content Container -->
|
||||
<div class="relative w-full max-w-md z-10">
|
||||
<div class="relative z-10 w-full max-w-md">
|
||||
<!-- Logo/Brand -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="mb-8 text-center">
|
||||
<!-- Custom Logo or Default Logo -->
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl overflow-hidden shadow-lg shadow-primary-500/30 mb-4">
|
||||
<img :src="siteLogo || '/logo.png'" alt="Logo" class="w-full h-full object-contain" />
|
||||
<div
|
||||
class="mb-4 inline-flex h-16 w-16 items-center justify-center overflow-hidden rounded-2xl shadow-lg shadow-primary-500/30"
|
||||
>
|
||||
<img :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-gradient mb-2">
|
||||
<h1 class="text-gradient mb-2 text-3xl font-bold">
|
||||
{{ siteName }}
|
||||
</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-dark-400">
|
||||
@@ -36,12 +48,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Footer Links -->
|
||||
<div class="text-center mt-6 text-sm">
|
||||
<div class="mt-6 text-center text-sm">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
|
||||
<!-- Copyright -->
|
||||
<div class="text-center mt-8 text-xs text-gray-400 dark:text-dark-500">
|
||||
<div class="mt-8 text-center text-xs text-gray-400 dark:text-dark-500">
|
||||
© {{ currentYear }} {{ siteName }}. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,25 +61,25 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { getPublicSettings } from '@/api/auth';
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { getPublicSettings } from '@/api/auth'
|
||||
|
||||
const siteName = ref('Sub2API');
|
||||
const siteLogo = ref('');
|
||||
const siteSubtitle = ref('Subscription to API Conversion Platform');
|
||||
const siteName = ref('Sub2API')
|
||||
const siteLogo = ref('')
|
||||
const siteSubtitle = ref('Subscription to API Conversion Platform')
|
||||
|
||||
const currentYear = computed(() => new Date().getFullYear());
|
||||
const currentYear = computed(() => new Date().getFullYear())
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const settings = await getPublicSettings();
|
||||
siteName.value = settings.site_name || 'Sub2API';
|
||||
siteLogo.value = settings.site_logo || '';
|
||||
siteSubtitle.value = settings.site_subtitle || 'Subscription to API Conversion Platform';
|
||||
const settings = await getPublicSettings()
|
||||
siteName.value = settings.site_name || 'Sub2API'
|
||||
siteLogo.value = settings.site_logo || ''
|
||||
siteSubtitle.value = settings.site_subtitle || 'Subscription to API Conversion Platform'
|
||||
} catch (error) {
|
||||
console.error('Failed to load public settings:', error);
|
||||
console.error('Failed to load public settings:', error)
|
||||
}
|
||||
});
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -8,31 +8,31 @@
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Dashboard</h1>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<!-- Stats Cards -->
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<div class="rounded-lg bg-white p-6 shadow">
|
||||
<div class="text-sm text-gray-600">API Keys</div>
|
||||
<div class="text-2xl font-bold text-gray-900">5</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<div class="rounded-lg bg-white p-6 shadow">
|
||||
<div class="text-sm text-gray-600">Total Usage</div>
|
||||
<div class="text-2xl font-bold text-gray-900">1,234</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<div class="rounded-lg bg-white p-6 shadow">
|
||||
<div class="text-sm text-gray-600">Balance</div>
|
||||
<div class="text-2xl font-bold text-indigo-600">${{ balance }}</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<div class="rounded-lg bg-white p-6 shadow">
|
||||
<div class="text-sm text-gray-600">Status</div>
|
||||
<div class="text-2xl font-bold text-green-600">Active</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<h2 class="text-xl font-semibold mb-4">Recent Activity</h2>
|
||||
<div class="rounded-lg bg-white p-6 shadow">
|
||||
<h2 class="mb-4 text-xl font-semibold">Recent Activity</h2>
|
||||
<p class="text-gray-600">No recent activity</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,12 +40,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { AppLayout } from '@/components/layout';
|
||||
import { useAuthStore } from '@/stores';
|
||||
import { computed } from 'vue'
|
||||
import { AppLayout } from '@/components/layout'
|
||||
import { useAuthStore } from '@/stores'
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00');
|
||||
const authStore = useAuthStore()
|
||||
const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00')
|
||||
</script>
|
||||
```
|
||||
|
||||
@@ -56,11 +56,11 @@ const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00');
|
||||
```vue
|
||||
<template>
|
||||
<AuthLayout>
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-6">Welcome Back</h2>
|
||||
<h2 class="mb-6 text-2xl font-bold text-gray-900">Welcome Back</h2>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label for="username" class="mb-1 block text-sm font-medium text-gray-700">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
@@ -68,13 +68,13 @@ const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00');
|
||||
v-model="form.username"
|
||||
type="text"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
class="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-transparent focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="Enter your username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label for="password" class="mb-1 block text-sm font-medium text-gray-700">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
@@ -82,7 +82,7 @@ const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00');
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
class="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-transparent focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
</div>
|
||||
@@ -90,7 +90,7 @@ const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00');
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="w-full bg-indigo-600 text-white py-2 px-4 rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
class="w-full rounded-lg bg-indigo-600 px-4 py-2 text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{{ loading ? 'Logging in...' : 'Login' }}
|
||||
</button>
|
||||
@@ -99,7 +99,7 @@ const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00');
|
||||
<template #footer>
|
||||
<p class="text-gray-600">
|
||||
Don't have an account?
|
||||
<router-link to="/register" class="text-indigo-600 hover:underline font-medium">
|
||||
<router-link to="/register" class="font-medium text-indigo-600 hover:underline">
|
||||
Sign up
|
||||
</router-link>
|
||||
</p>
|
||||
@@ -108,32 +108,32 @@ const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00');
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { AuthLayout } from '@/components/layout';
|
||||
import { useAuthStore, useAppStore } from '@/stores';
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const appStore = useAppStore();
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const form = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
password: ''
|
||||
})
|
||||
|
||||
const loading = ref(false);
|
||||
const loading = ref(false)
|
||||
|
||||
async function handleSubmit() {
|
||||
loading.value = true;
|
||||
loading.value = true
|
||||
try {
|
||||
await authStore.login(form.value);
|
||||
appStore.showSuccess('Login successful!');
|
||||
await router.push('/dashboard');
|
||||
await authStore.login(form.value)
|
||||
appStore.showSuccess('Login successful!')
|
||||
await router.push('/dashboard')
|
||||
} catch (error) {
|
||||
appStore.showError('Invalid username or password');
|
||||
appStore.showError('Invalid username or password')
|
||||
} finally {
|
||||
loading.value = false;
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -152,42 +152,42 @@ async function handleSubmit() {
|
||||
<h1 class="text-3xl font-bold text-gray-900">API Keys</h1>
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
class="rounded-lg bg-indigo-600 px-4 py-2 text-white transition-colors hover:bg-indigo-700"
|
||||
>
|
||||
Create New Key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- API Keys List -->
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div class="overflow-hidden rounded-lg bg-white shadow">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Name
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Key
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">Name</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">Key</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
Status
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
Created
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
<th class="px-6 py-3 text-right text-xs font-medium uppercase text-gray-500">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
<tr v-for="key in apiKeys" :key="key.id">
|
||||
<td class="px-6 py-4 whitespace-nowrap">{{ key.name }}</td>
|
||||
<td class="whitespace-nowrap px-6 py-4">{{ key.name }}</td>
|
||||
<td class="px-6 py-4 font-mono text-sm">{{ key.key }}</td>
|
||||
<td class="px-6 py-4">
|
||||
<span
|
||||
class="px-2 py-1 text-xs rounded-full"
|
||||
:class="key.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'"
|
||||
class="rounded-full px-2 py-1 text-xs"
|
||||
:class="
|
||||
key.status === 'active'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
"
|
||||
>
|
||||
{{ key.status }}
|
||||
</span>
|
||||
@@ -196,9 +196,7 @@ async function handleSubmit() {
|
||||
{{ new Date(key.created_at).toLocaleDateString() }}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<button class="text-red-600 hover:text-red-800 text-sm">
|
||||
Delete
|
||||
</button>
|
||||
<button class="text-sm text-red-600 hover:text-red-800">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -209,12 +207,12 @@ async function handleSubmit() {
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { AppLayout } from '@/components/layout';
|
||||
import type { ApiKey } from '@/types';
|
||||
import { ref } from 'vue'
|
||||
import { AppLayout } from '@/components/layout'
|
||||
import type { ApiKey } from '@/types'
|
||||
|
||||
const showCreateModal = ref(false);
|
||||
const apiKeys = ref<ApiKey[]>([]);
|
||||
const showCreateModal = ref(false)
|
||||
const apiKeys = ref<ApiKey[]>([])
|
||||
|
||||
// Fetch API keys on mount
|
||||
// fetchApiKeys();
|
||||
@@ -233,34 +231,40 @@ const apiKeys = ref<ApiKey[]>([]);
|
||||
<h1 class="text-3xl font-bold text-gray-900">User Management</h1>
|
||||
<button
|
||||
@click="showCreateUser = true"
|
||||
class="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
class="rounded-lg bg-indigo-600 px-4 py-2 text-white transition-colors hover:bg-indigo-700"
|
||||
>
|
||||
Create User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Users Table -->
|
||||
<div class="bg-white rounded-lg shadow">
|
||||
<div class="rounded-lg bg-white shadow">
|
||||
<div class="p-6">
|
||||
<div class="space-y-4">
|
||||
<div v-for="user in users" :key="user.id" class="flex items-center justify-between border-b pb-4">
|
||||
<div
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
class="flex items-center justify-between border-b pb-4"
|
||||
>
|
||||
<div>
|
||||
<div class="font-medium text-gray-900">{{ user.username }}</div>
|
||||
<div class="text-sm text-gray-500">{{ user.email }}</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<span
|
||||
class="px-2 py-1 text-xs rounded-full"
|
||||
:class="user.role === 'admin' ? 'bg-purple-100 text-purple-800' : 'bg-blue-100 text-blue-800'"
|
||||
class="rounded-full px-2 py-1 text-xs"
|
||||
:class="
|
||||
user.role === 'admin'
|
||||
? 'bg-purple-100 text-purple-800'
|
||||
: 'bg-blue-100 text-blue-800'
|
||||
"
|
||||
>
|
||||
{{ user.role }}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-700">
|
||||
${{ user.balance.toFixed(2) }}
|
||||
</span>
|
||||
<button class="text-indigo-600 hover:text-indigo-800 text-sm">
|
||||
Edit
|
||||
</button>
|
||||
<button class="text-sm text-indigo-600 hover:text-indigo-800">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -271,12 +275,12 @@ const apiKeys = ref<ApiKey[]>([]);
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { AppLayout } from '@/components/layout';
|
||||
import type { User } from '@/types';
|
||||
import { ref } from 'vue'
|
||||
import { AppLayout } from '@/components/layout'
|
||||
import type { User } from '@/types'
|
||||
|
||||
const showCreateUser = ref(false);
|
||||
const users = ref<User[]>([]);
|
||||
const showCreateUser = ref(false)
|
||||
const users = ref<User[]>([])
|
||||
|
||||
// Fetch users on mount
|
||||
// fetchUsers();
|
||||
@@ -294,36 +298,34 @@ const users = ref<User[]>([]);
|
||||
<h1 class="text-3xl font-bold text-gray-900">Profile Settings</h1>
|
||||
|
||||
<!-- User Info Card -->
|
||||
<div class="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<div class="space-y-4 rounded-lg bg-white p-6 shadow">
|
||||
<h2 class="text-xl font-semibold text-gray-900">Account Information</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Username
|
||||
</label>
|
||||
<div class="px-3 py-2 bg-gray-50 rounded-lg text-gray-900">
|
||||
<label class="mb-1 block text-sm font-medium text-gray-700"> Username </label>
|
||||
<div class="rounded-lg bg-gray-50 px-3 py-2 text-gray-900">
|
||||
{{ user?.username }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<div class="px-3 py-2 bg-gray-50 rounded-lg text-gray-900">
|
||||
<label class="mb-1 block text-sm font-medium text-gray-700"> Email </label>
|
||||
<div class="rounded-lg bg-gray-50 px-3 py-2 text-gray-900">
|
||||
{{ user?.email }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Role
|
||||
</label>
|
||||
<div class="px-3 py-2 bg-gray-50 rounded-lg">
|
||||
<label class="mb-1 block text-sm font-medium text-gray-700"> Role </label>
|
||||
<div class="rounded-lg bg-gray-50 px-3 py-2">
|
||||
<span
|
||||
class="px-2 py-1 text-xs rounded-full"
|
||||
:class="user?.role === 'admin' ? 'bg-purple-100 text-purple-800' : 'bg-blue-100 text-blue-800'"
|
||||
class="rounded-full px-2 py-1 text-xs"
|
||||
:class="
|
||||
user?.role === 'admin'
|
||||
? 'bg-purple-100 text-purple-800'
|
||||
: 'bg-blue-100 text-blue-800'
|
||||
"
|
||||
>
|
||||
{{ user?.role }}
|
||||
</span>
|
||||
@@ -331,10 +333,8 @@ const users = ref<User[]>([]);
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Balance
|
||||
</label>
|
||||
<div class="px-3 py-2 bg-gray-50 rounded-lg text-indigo-600 font-semibold">
|
||||
<label class="mb-1 block text-sm font-medium text-gray-700"> Balance </label>
|
||||
<div class="rounded-lg bg-gray-50 px-3 py-2 font-semibold text-indigo-600">
|
||||
${{ user?.balance.toFixed(2) }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -342,12 +342,12 @@ const users = ref<User[]>([]);
|
||||
</div>
|
||||
|
||||
<!-- Change Password Card -->
|
||||
<div class="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<div class="space-y-4 rounded-lg bg-white p-6 shadow">
|
||||
<h2 class="text-xl font-semibold text-gray-900">Change Password</h2>
|
||||
|
||||
<form @submit.prevent="handleChangePassword" class="space-y-4">
|
||||
<div>
|
||||
<label for="old-password" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label for="old-password" class="mb-1 block text-sm font-medium text-gray-700">
|
||||
Current Password
|
||||
</label>
|
||||
<input
|
||||
@@ -355,12 +355,12 @@ const users = ref<User[]>([]);
|
||||
v-model="passwordForm.old_password"
|
||||
type="password"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
||||
class="w-full rounded-lg border border-gray-300 px-3 py-2 focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="new-password" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label for="new-password" class="mb-1 block text-sm font-medium text-gray-700">
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
@@ -368,13 +368,13 @@ const users = ref<User[]>([]);
|
||||
v-model="passwordForm.new_password"
|
||||
type="password"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
||||
class="w-full rounded-lg border border-gray-300 px-3 py-2 focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
class="rounded-lg bg-indigo-600 px-4 py-2 text-white transition-colors hover:bg-indigo-700"
|
||||
>
|
||||
Update Password
|
||||
</button>
|
||||
@@ -385,27 +385,27 @@ const users = ref<User[]>([]);
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { AppLayout } from '@/components/layout';
|
||||
import { useAuthStore, useAppStore } from '@/stores';
|
||||
import { ref, computed } from 'vue'
|
||||
import { AppLayout } from '@/components/layout'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const appStore = useAppStore();
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const user = computed(() => authStore.user);
|
||||
const user = computed(() => authStore.user)
|
||||
|
||||
const passwordForm = ref({
|
||||
old_password: '',
|
||||
new_password: '',
|
||||
});
|
||||
new_password: ''
|
||||
})
|
||||
|
||||
async function handleChangePassword() {
|
||||
try {
|
||||
// await changePasswordAPI(passwordForm.value);
|
||||
appStore.showSuccess('Password updated successfully!');
|
||||
passwordForm.value = { old_password: '', new_password: '' };
|
||||
appStore.showSuccess('Password updated successfully!')
|
||||
passwordForm.value = { old_password: '', new_password: '' }
|
||||
} catch (error) {
|
||||
appStore.showError('Failed to update password');
|
||||
appStore.showError('Failed to update password')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -6,20 +6,20 @@
|
||||
|
||||
```typescript
|
||||
// In your view files
|
||||
import { AppLayout, AuthLayout } from '@/components/layout';
|
||||
import { AppLayout, AuthLayout } from '@/components/layout'
|
||||
```
|
||||
|
||||
### 2. Use in Routes
|
||||
|
||||
```typescript
|
||||
// src/router/index.ts
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
// Views
|
||||
import DashboardView from '@/views/DashboardView.vue';
|
||||
import LoginView from '@/views/auth/LoginView.vue';
|
||||
import RegisterView from '@/views/auth/RegisterView.vue';
|
||||
import DashboardView from '@/views/DashboardView.vue'
|
||||
import LoginView from '@/views/auth/LoginView.vue'
|
||||
import RegisterView from '@/views/auth/RegisterView.vue'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
// Auth routes (no layout needed - views use AuthLayout internally)
|
||||
@@ -27,13 +27,13 @@ const routes: RouteRecordRaw[] = [
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: LoginView,
|
||||
meta: { requiresAuth: false },
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: RegisterView,
|
||||
meta: { requiresAuth: false },
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
|
||||
// User routes (use AppLayout)
|
||||
@@ -41,31 +41,31 @@ const routes: RouteRecordRaw[] = [
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: DashboardView,
|
||||
meta: { requiresAuth: true, title: 'Dashboard' },
|
||||
meta: { requiresAuth: true, title: 'Dashboard' }
|
||||
},
|
||||
{
|
||||
path: '/api-keys',
|
||||
name: 'ApiKeys',
|
||||
component: () => import('@/views/ApiKeysView.vue'),
|
||||
meta: { requiresAuth: true, title: 'API Keys' },
|
||||
meta: { requiresAuth: true, title: 'API Keys' }
|
||||
},
|
||||
{
|
||||
path: '/usage',
|
||||
name: 'Usage',
|
||||
component: () => import('@/views/UsageView.vue'),
|
||||
meta: { requiresAuth: true, title: 'Usage Statistics' },
|
||||
meta: { requiresAuth: true, title: 'Usage Statistics' }
|
||||
},
|
||||
{
|
||||
path: '/redeem',
|
||||
name: 'Redeem',
|
||||
component: () => import('@/views/RedeemView.vue'),
|
||||
meta: { requiresAuth: true, title: 'Redeem Code' },
|
||||
meta: { requiresAuth: true, title: 'Redeem Code' }
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'Profile',
|
||||
component: () => import('@/views/ProfileView.vue'),
|
||||
meta: { requiresAuth: true, title: 'Profile Settings' },
|
||||
meta: { requiresAuth: true, title: 'Profile Settings' }
|
||||
},
|
||||
|
||||
// Admin routes (use AppLayout, admin only)
|
||||
@@ -73,91 +73,91 @@ const routes: RouteRecordRaw[] = [
|
||||
path: '/admin/dashboard',
|
||||
name: 'AdminDashboard',
|
||||
component: () => import('@/views/admin/DashboardView.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'Admin Dashboard' },
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'Admin Dashboard' }
|
||||
},
|
||||
{
|
||||
path: '/admin/users',
|
||||
name: 'AdminUsers',
|
||||
component: () => import('@/views/admin/UsersView.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'User Management' },
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'User Management' }
|
||||
},
|
||||
{
|
||||
path: '/admin/groups',
|
||||
name: 'AdminGroups',
|
||||
component: () => import('@/views/admin/GroupsView.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'Groups' },
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'Groups' }
|
||||
},
|
||||
{
|
||||
path: '/admin/accounts',
|
||||
name: 'AdminAccounts',
|
||||
component: () => import('@/views/admin/AccountsView.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'Accounts' },
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'Accounts' }
|
||||
},
|
||||
{
|
||||
path: '/admin/proxies',
|
||||
name: 'AdminProxies',
|
||||
component: () => import('@/views/admin/ProxiesView.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'Proxies' },
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'Proxies' }
|
||||
},
|
||||
{
|
||||
path: '/admin/redeem-codes',
|
||||
name: 'AdminRedeemCodes',
|
||||
component: () => import('@/views/admin/RedeemCodesView.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'Redeem Codes' },
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'Redeem Codes' }
|
||||
},
|
||||
|
||||
// Default redirect
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/dashboard',
|
||||
},
|
||||
];
|
||||
redirect: '/dashboard'
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
});
|
||||
routes
|
||||
})
|
||||
|
||||
// Navigation guards
|
||||
router.beforeEach((to, from, next) => {
|
||||
const authStore = useAuthStore();
|
||||
const authStore = useAuthStore()
|
||||
|
||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||
// Redirect to login if not authenticated
|
||||
next('/login');
|
||||
next('/login')
|
||||
} else if (to.meta.requiresAdmin && !authStore.isAdmin) {
|
||||
// Redirect to dashboard if not admin
|
||||
next('/dashboard');
|
||||
next('/dashboard')
|
||||
} else {
|
||||
next();
|
||||
next()
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
export default router;
|
||||
export default router
|
||||
```
|
||||
|
||||
### 3. Initialize Stores in main.ts
|
||||
|
||||
```typescript
|
||||
// src/main.ts
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import './style.css';
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
|
||||
// Initialize auth state on app startup
|
||||
import { useAuthStore } from '@/stores';
|
||||
const authStore = useAuthStore();
|
||||
authStore.checkAuth();
|
||||
import { useAuthStore } from '@/stores'
|
||||
const authStore = useAuthStore()
|
||||
authStore.checkAuth()
|
||||
|
||||
app.mount('#app');
|
||||
app.mount('#app')
|
||||
```
|
||||
|
||||
### 4. Update App.vue
|
||||
@@ -193,7 +193,7 @@ app.mount('#app');
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AppLayout } from '@/components/layout';
|
||||
import { AppLayout } from '@/components/layout'
|
||||
|
||||
// Your component logic here
|
||||
</script>
|
||||
@@ -205,23 +205,21 @@ import { AppLayout } from '@/components/layout';
|
||||
<!-- src/views/auth/LoginView.vue -->
|
||||
<template>
|
||||
<AuthLayout>
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-6">Login</h2>
|
||||
<h2 class="mb-6 text-2xl font-bold text-gray-900">Login</h2>
|
||||
|
||||
<!-- Your login form here -->
|
||||
|
||||
<template #footer>
|
||||
<p class="text-gray-600">
|
||||
Don't have an account?
|
||||
<router-link to="/register" class="text-indigo-600 hover:underline">
|
||||
Sign up
|
||||
</router-link>
|
||||
<router-link to="/register" class="text-indigo-600 hover:underline"> Sign up </router-link>
|
||||
</p>
|
||||
</template>
|
||||
</AuthLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AuthLayout } from '@/components/layout';
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
|
||||
// Your login logic here
|
||||
</script>
|
||||
@@ -250,7 +248,7 @@ Replace HTML entity icons with your preferred icon library:
|
||||
<span class="text-lg">📈</span>
|
||||
|
||||
<!-- After (Heroicons example) -->
|
||||
<ChartBarIcon class="w-5 h-5" />
|
||||
<ChartBarIcon class="h-5 w-5" />
|
||||
```
|
||||
|
||||
### Sidebar Customization
|
||||
@@ -261,9 +259,9 @@ Modify navigation items in `AppSidebar.vue`:
|
||||
// Add/remove/modify navigation items
|
||||
const userNavItems = [
|
||||
{ path: '/dashboard', label: 'Dashboard', icon: '📈' },
|
||||
{ path: '/new-page', label: 'New Page', icon: '📄' }, // Add new item
|
||||
{ path: '/new-page', label: 'New Page', icon: '📄' } // Add new item
|
||||
// ...
|
||||
];
|
||||
]
|
||||
```
|
||||
|
||||
### Header Customization
|
||||
@@ -287,10 +285,12 @@ Modify user dropdown in `AppHeader.vue`:
|
||||
## Mobile Responsive Behavior
|
||||
|
||||
### Sidebar
|
||||
|
||||
- **Desktop (md+)**: Always visible, can be collapsed to icon-only view
|
||||
- **Mobile**: Hidden by default, shown via menu toggle in header
|
||||
|
||||
### Header
|
||||
|
||||
- **Desktop**: Shows full user info and balance
|
||||
- **Mobile**: Shows compact view with hamburger menu
|
||||
|
||||
@@ -299,7 +299,7 @@ To improve mobile experience, you can add overlay and transitions:
|
||||
```vue
|
||||
<!-- AppSidebar.vue enhancement for mobile -->
|
||||
<aside
|
||||
class="fixed left-0 top-0 h-screen transition-transform duration-300 z-40"
|
||||
class="fixed left-0 top-0 z-40 h-screen transition-transform duration-300"
|
||||
:class="[
|
||||
sidebarCollapsed ? 'w-16' : 'w-64',
|
||||
// Hide on mobile when collapsed
|
||||
@@ -314,7 +314,7 @@ To improve mobile experience, you can add overlay and transitions:
|
||||
<div
|
||||
v-if="!sidebarCollapsed"
|
||||
@click="toggleSidebar"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 z-30 md:hidden"
|
||||
class="fixed inset-0 z-30 bg-black bg-opacity-50 md:hidden"
|
||||
></div>
|
||||
```
|
||||
|
||||
@@ -325,9 +325,9 @@ To improve mobile experience, you can add overlay and transitions:
|
||||
### Auth Store Usage
|
||||
|
||||
```typescript
|
||||
import { useAuthStore } from '@/stores';
|
||||
import { useAuthStore } from '@/stores'
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Check if user is authenticated
|
||||
if (authStore.isAuthenticated) {
|
||||
@@ -340,34 +340,34 @@ if (authStore.isAdmin) {
|
||||
}
|
||||
|
||||
// Get current user
|
||||
const user = authStore.user;
|
||||
const user = authStore.user
|
||||
```
|
||||
|
||||
### App Store Usage
|
||||
|
||||
```typescript
|
||||
import { useAppStore } from '@/stores';
|
||||
import { useAppStore } from '@/stores'
|
||||
|
||||
const appStore = useAppStore();
|
||||
const appStore = useAppStore()
|
||||
|
||||
// Toggle sidebar
|
||||
appStore.toggleSidebar();
|
||||
appStore.toggleSidebar()
|
||||
|
||||
// Show notifications
|
||||
appStore.showSuccess('Operation completed!');
|
||||
appStore.showError('Something went wrong');
|
||||
appStore.showInfo('Did you know...');
|
||||
appStore.showWarning('Be careful!');
|
||||
appStore.showSuccess('Operation completed!')
|
||||
appStore.showError('Something went wrong')
|
||||
appStore.showInfo('Did you know...')
|
||||
appStore.showWarning('Be careful!')
|
||||
|
||||
// Loading state
|
||||
appStore.setLoading(true);
|
||||
appStore.setLoading(true)
|
||||
// ... perform operation
|
||||
appStore.setLoading(false);
|
||||
appStore.setLoading(false)
|
||||
|
||||
// Or use helper
|
||||
await appStore.withLoading(async () => {
|
||||
// Your async operation
|
||||
});
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
@@ -388,7 +388,7 @@ To enhance further:
|
||||
<!-- Add skip to main content link -->
|
||||
<a
|
||||
href="#main-content"
|
||||
class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 bg-white px-4 py-2 rounded"
|
||||
class="sr-only rounded bg-white px-4 py-2 focus:not-sr-only focus:absolute focus:left-4 focus:top-4"
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
@@ -406,27 +406,27 @@ To enhance further:
|
||||
|
||||
```typescript
|
||||
// AppHeader.test.ts
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import AppHeader from '@/components/layout/AppHeader.vue';
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import AppHeader from '@/components/layout/AppHeader.vue'
|
||||
|
||||
describe('AppHeader', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('renders user info when authenticated', () => {
|
||||
const wrapper = mount(AppHeader);
|
||||
const wrapper = mount(AppHeader)
|
||||
// Add assertions
|
||||
});
|
||||
})
|
||||
|
||||
it('shows dropdown when clicked', async () => {
|
||||
const wrapper = mount(AppHeader);
|
||||
await wrapper.find('button').trigger('click');
|
||||
expect(wrapper.find('.dropdown').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
const wrapper = mount(AppHeader)
|
||||
await wrapper.find('button').trigger('click')
|
||||
expect(wrapper.find('.dropdown').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
@@ -443,7 +443,7 @@ Layout components are automatically code-split when imported:
|
||||
|
||||
```typescript
|
||||
// This creates a separate chunk for layout components
|
||||
import { AppLayout } from '@/components/layout';
|
||||
import { AppLayout } from '@/components/layout'
|
||||
```
|
||||
|
||||
### Reducing Re-renders
|
||||
@@ -451,7 +451,7 @@ import { AppLayout } from '@/components/layout';
|
||||
Layout components use `computed` refs to prevent unnecessary re-renders:
|
||||
|
||||
```typescript
|
||||
const sidebarCollapsed = computed(() => appStore.sidebarCollapsed);
|
||||
const sidebarCollapsed = computed(() => appStore.sidebarCollapsed)
|
||||
// This only re-renders when sidebarCollapsed changes
|
||||
```
|
||||
|
||||
@@ -460,21 +460,25 @@ const sidebarCollapsed = computed(() => appStore.sidebarCollapsed);
|
||||
## Troubleshooting
|
||||
|
||||
### Sidebar not showing
|
||||
|
||||
- Check if `useAppStore` is properly initialized
|
||||
- Verify Tailwind classes are being processed
|
||||
- Check z-index conflicts with other components
|
||||
|
||||
### Routes not highlighting in sidebar
|
||||
|
||||
- Ensure route paths match exactly
|
||||
- Check `isActive()` function logic
|
||||
- Verify `useRoute()` is working correctly
|
||||
|
||||
### User info not displaying
|
||||
|
||||
- Ensure auth store is initialized with `checkAuth()`
|
||||
- Verify user is logged in
|
||||
- Check localStorage for auth data
|
||||
|
||||
### Mobile menu not working
|
||||
|
||||
- Verify `toggleSidebar()` is called correctly
|
||||
- Check responsive breakpoints (md:)
|
||||
- Test on actual mobile device or browser dev tools
|
||||
|
||||
@@ -5,9 +5,11 @@ Vue 3 layout components for the Sub2API frontend, built with Composition API, Ty
|
||||
## Components
|
||||
|
||||
### 1. AppLayout.vue
|
||||
|
||||
Main application layout with sidebar and header.
|
||||
|
||||
**Usage:**
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<AppLayout>
|
||||
@@ -18,11 +20,12 @@ Main application layout with sidebar and header.
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AppLayout } from '@/components/layout';
|
||||
import { AppLayout } from '@/components/layout'
|
||||
</script>
|
||||
```
|
||||
|
||||
**Features:**
|
||||
|
||||
- Responsive sidebar (collapsible)
|
||||
- Fixed header at top
|
||||
- Main content area with slot
|
||||
@@ -31,9 +34,11 @@ import { AppLayout } from '@/components/layout';
|
||||
---
|
||||
|
||||
### 2. AppSidebar.vue
|
||||
|
||||
Navigation sidebar with user and admin sections.
|
||||
|
||||
**Features:**
|
||||
|
||||
- Logo/brand at top
|
||||
- User navigation links:
|
||||
- Dashboard
|
||||
@@ -58,9 +63,11 @@ Navigation sidebar with user and admin sections.
|
||||
---
|
||||
|
||||
### 3. AppHeader.vue
|
||||
|
||||
Top header with user info and actions.
|
||||
|
||||
**Features:**
|
||||
|
||||
- Mobile menu toggle button
|
||||
- Page title (from route meta or slot)
|
||||
- User balance display (desktop only)
|
||||
@@ -72,12 +79,11 @@ Top header with user info and actions.
|
||||
- Responsive design
|
||||
|
||||
**Usage with custom title:**
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<AppLayout>
|
||||
<template #title>
|
||||
Custom Page Title
|
||||
</template>
|
||||
<template #title> Custom Page Title </template>
|
||||
|
||||
<!-- Your content -->
|
||||
</AppLayout>
|
||||
@@ -89,14 +95,16 @@ Top header with user info and actions.
|
||||
---
|
||||
|
||||
### 4. AuthLayout.vue
|
||||
|
||||
Simple centered layout for authentication pages (login/register).
|
||||
|
||||
**Usage:**
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<AuthLayout>
|
||||
<!-- Login/Register form content -->
|
||||
<h2 class="text-2xl font-bold mb-6">Login</h2>
|
||||
<h2 class="mb-6 text-2xl font-bold">Login</h2>
|
||||
|
||||
<form @submit.prevent="handleLogin">
|
||||
<!-- Form fields -->
|
||||
@@ -106,16 +114,14 @@ Simple centered layout for authentication pages (login/register).
|
||||
<template #footer>
|
||||
<p>
|
||||
Don't have an account?
|
||||
<router-link to="/register" class="text-indigo-600 hover:underline">
|
||||
Sign up
|
||||
</router-link>
|
||||
<router-link to="/register" class="text-indigo-600 hover:underline"> Sign up </router-link>
|
||||
</p>
|
||||
</template>
|
||||
</AuthLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AuthLayout } from '@/components/layout';
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
|
||||
function handleLogin() {
|
||||
// Login logic
|
||||
@@ -124,6 +130,7 @@ function handleLogin() {
|
||||
```
|
||||
|
||||
**Features:**
|
||||
|
||||
- Centered card container
|
||||
- Gradient background
|
||||
- Logo/brand at top
|
||||
@@ -143,15 +150,15 @@ const routes = [
|
||||
{
|
||||
path: '/dashboard',
|
||||
component: DashboardView,
|
||||
meta: { title: 'Dashboard' },
|
||||
meta: { title: 'Dashboard' }
|
||||
},
|
||||
{
|
||||
path: '/api-keys',
|
||||
component: ApiKeysView,
|
||||
meta: { title: 'API Keys' },
|
||||
},
|
||||
meta: { title: 'API Keys' }
|
||||
}
|
||||
// ...
|
||||
];
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
@@ -173,10 +180,7 @@ All components use TailwindCSS utility classes. Make sure your `tailwind.config.
|
||||
|
||||
```js
|
||||
module.exports = {
|
||||
content: [
|
||||
'./index.html',
|
||||
'./src/**/*.{vue,js,ts,jsx,tsx}',
|
||||
],
|
||||
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}']
|
||||
// ...
|
||||
}
|
||||
```
|
||||
@@ -186,6 +190,7 @@ module.exports = {
|
||||
## Icons
|
||||
|
||||
Components use HTML entity icons for simplicity:
|
||||
|
||||
- 📈 Chart (Dashboard)
|
||||
- 🔑 Key (API Keys)
|
||||
- 📊 Bar Chart (Usage)
|
||||
@@ -205,6 +210,7 @@ You can replace these with your preferred icon library (e.g., Heroicons, Font Aw
|
||||
## Mobile Responsiveness
|
||||
|
||||
All components are fully responsive:
|
||||
|
||||
- **AppSidebar**: Fixed positioning on desktop, hidden by default on mobile
|
||||
- **AppHeader**: Shows mobile menu toggle on small screens, hides balance display
|
||||
- **AuthLayout**: Adapts padding and card size for mobile devices
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Export all layout components for easy importing
|
||||
*/
|
||||
|
||||
export { default as AppLayout } from './AppLayout.vue';
|
||||
export { default as AppSidebar } from './AppSidebar.vue';
|
||||
export { default as AppHeader } from './AppHeader.vue';
|
||||
export { default as AuthLayout } from './AuthLayout.vue';
|
||||
export { default as AppLayout } from './AppLayout.vue'
|
||||
export { default as AppSidebar } from './AppSidebar.vue'
|
||||
export { default as AppHeader } from './AppHeader.vue'
|
||||
export { default as AuthLayout } from './AuthLayout.vue'
|
||||
|
||||
Reference in New Issue
Block a user