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

- 统一移除语句末尾分号,规范代码格式
- 优化组件类型定义和 props 声明
- 改进组件文档和示例代码
- 提升代码可读性和一致性
This commit is contained in:
ianshaw
2025-12-25 08:40:12 -08:00
parent 1ac8b1f03e
commit 5deef27e1d
38 changed files with 2582 additions and 1485 deletions

View File

@@ -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'

View File

@@ -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] }}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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'

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 */

View File

@@ -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

View File

@@ -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;

View File

@@ -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"
>

View File

@@ -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>

View File

@@ -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)}%` }"

View File

@@ -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>

View File

@@ -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>