First commit

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

View File

@@ -0,0 +1,65 @@
<template>
<Modal :show="show" :title="title" size="sm" @close="handleCancel">
<div class="space-y-4">
<p class="text-sm text-gray-600 dark:text-gray-400">{{ message }}</p>
</div>
<template #footer>
<div class="flex justify-end space-x-3">
<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"
>
{{ cancelText }}
</button>
<button
@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',
danger
? 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
: 'bg-primary-600 hover:bg-primary-700 focus:ring-primary-500'
]"
>
{{ confirmText }}
</button>
</div>
</template>
</Modal>
</template>
<script setup lang="ts">
import Modal from './Modal.vue'
interface Props {
show: boolean
title: string
message: string
confirmText?: string
cancelText?: string
danger?: boolean
}
interface Emits {
(e: 'confirm'): void
(e: 'cancel'): void
}
const props = withDefaults(defineProps<Props>(), {
confirmText: 'Confirm',
cancelText: 'Cancel',
danger: false
})
const emit = defineEmits<Emits>()
const handleConfirm = () => {
emit('confirm')
}
const handleCancel = () => {
emit('cancel')
}
</script>

View File

@@ -0,0 +1,134 @@
<template>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
<thead class="bg-gray-50 dark:bg-dark-800">
<tr>
<th
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="{ 'cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-700': column.sortable }"
@click="column.sortable && handleSort(column.key)"
>
<div class="flex items-center space-x-1">
<span>{{ column.label }}</span>
<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' }"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z"
clip-rule="evenodd"
/>
</svg>
<svg v-else class="w-4 h-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"
/>
</svg>
</span>
</div>
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-dark-900 divide-y divide-gray-200 dark:divide-dark-700">
<!-- 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">
<div class="animate-pulse">
<div class="h-4 bg-gray-200 dark:bg-dark-700 rounded w-3/4"></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">
<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">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
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>
</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">
<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"
>
<slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]">
{{ column.formatter ? column.formatter(row[column.key], row) : row[column.key] }}
</slot>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
export interface Column {
key: string
label: string
sortable?: boolean
formatter?: (value: any, row: any) => string
}
interface Props {
columns: Column[]
data: any[]
loading?: boolean
}
const props = withDefaults(defineProps<Props>(), {
loading: false
})
const sortKey = ref<string>('')
const sortOrder = ref<'asc' | 'desc'>('asc')
const handleSort = (key: string) => {
if (sortKey.value === key) {
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
} else {
sortKey.value = key
sortOrder.value = 'asc'
}
}
const sortedData = computed(() => {
if (!sortKey.value || !props.data) return props.data
return [...props.data].sort((a, b) => {
const aVal = a[sortKey.value]
const bVal = b[sortKey.value]
if (aVal === bVal) return 0
const comparison = aVal > bVal ? 1 : -1
return sortOrder.value === 'asc' ? comparison : -comparison
})
})
</script>

View File

@@ -0,0 +1,415 @@
<template>
<div class="relative" ref="containerRef">
<button
type="button"
@click="toggle"
: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>
</span>
<span class="date-picker-value">
{{ displayValue }}
</span>
<span class="date-picker-chevron">
<svg
:class="['w-4 h-4 transition-transform duration-200', isOpen && 'rotate-180']"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</span>
</button>
<Transition name="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'
]"
>
{{ t(preset.labelKey) }}
</button>
</div>
<div class="date-picker-divider"></div>
<!-- Custom date range inputs -->
<div class="date-picker-custom">
<div class="date-picker-field">
<label class="date-picker-label">{{ t('dates.startDate') }}</label>
<input
type="date"
v-model="localStartDate"
:max="localEndDate || today"
class="date-picker-input"
@change="onDateChange"
/>
</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>
</div>
<div class="date-picker-field">
<label class="date-picker-label">{{ t('dates.endDate') }}</label>
<input
type="date"
v-model="localEndDate"
:min="localStartDate"
:max="today"
class="date-picker-input"
@change="onDateChange"
/>
</div>
</div>
<!-- Apply button -->
<div class="date-picker-actions">
<button
@click="apply"
class="date-picker-apply"
>
{{ t('dates.apply') }}
</button>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
interface DatePreset {
labelKey: string
value: string
getRange: () => { start: string; end: string }
}
interface Props {
startDate: string
endDate: string
}
interface Emits {
(e: 'update:startDate', value: string): void
(e: 'update:endDate', value: string): void
(e: 'change', range: { startDate: string; endDate: string; preset: string | null }): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { t, locale } = useI18n()
const isOpen = ref(false)
const containerRef = ref<HTMLElement | null>(null)
const localStartDate = ref(props.startDate)
const localEndDate = ref(props.endDate)
const activePreset = ref<string | null>('7days')
const today = computed(() => new Date().toISOString().split('T')[0])
const presets: DatePreset[] = [
{
labelKey: 'dates.today',
value: 'today',
getRange: () => {
const t = today.value
return { start: t, end: t }
}
},
{
labelKey: 'dates.yesterday',
value: 'yesterday',
getRange: () => {
const d = new Date()
d.setDate(d.getDate() - 1)
const yesterday = d.toISOString().split('T')[0]
return { start: yesterday, end: yesterday }
}
},
{
labelKey: 'dates.last7Days',
value: '7days',
getRange: () => {
const end = today.value
const d = new Date()
d.setDate(d.getDate() - 6)
const start = d.toISOString().split('T')[0]
return { start, end }
}
},
{
labelKey: 'dates.last14Days',
value: '14days',
getRange: () => {
const end = today.value
const d = new Date()
d.setDate(d.getDate() - 13)
const start = d.toISOString().split('T')[0]
return { start, end }
}
},
{
labelKey: 'dates.last30Days',
value: '30days',
getRange: () => {
const end = today.value
const d = new Date()
d.setDate(d.getDate() - 29)
const start = d.toISOString().split('T')[0]
return { start, end }
}
},
{
labelKey: 'dates.thisMonth',
value: 'thisMonth',
getRange: () => {
const now = new Date()
const start = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().split('T')[0]
return { start, end: today.value }
}
},
{
labelKey: 'dates.lastMonth',
value: 'lastMonth',
getRange: () => {
const now = new Date()
const start = new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString().split('T')[0]
const end = new Date(now.getFullYear(), now.getMonth(), 0).toISOString().split('T')[0]
return { start, end }
}
}
]
const displayValue = computed(() => {
if (activePreset.value) {
const preset = presets.find(p => p.value === activePreset.value)
if (preset) return t(preset.labelKey)
}
if (localStartDate.value && localEndDate.value) {
if (localStartDate.value === localEndDate.value) {
return formatDate(localStartDate.value)
}
return `${formatDate(localStartDate.value)} - ${formatDate(localEndDate.value)}`
}
return t('dates.selectDateRange')
})
const formatDate = (dateStr: string): string => {
const date = new Date(dateStr + 'T00:00:00')
const dateLocale = locale.value === 'zh' ? 'zh-CN' : 'en-US'
return date.toLocaleDateString(dateLocale, { month: 'short', day: 'numeric' })
}
const isPresetActive = (preset: DatePreset): boolean => {
return activePreset.value === preset.value
}
const selectPreset = (preset: DatePreset) => {
const range = preset.getRange()
localStartDate.value = range.start
localEndDate.value = range.end
activePreset.value = preset.value
}
const onDateChange = () => {
// Check if current dates match any preset
activePreset.value = null
for (const preset of presets) {
const range = preset.getRange()
if (range.start === localStartDate.value && range.end === localEndDate.value) {
activePreset.value = preset.value
break
}
}
}
const toggle = () => {
isOpen.value = !isOpen.value
}
const apply = () => {
emit('update:startDate', localStartDate.value)
emit('update:endDate', localEndDate.value)
emit('change', {
startDate: localStartDate.value,
endDate: localEndDate.value,
preset: activePreset.value
})
isOpen.value = false
}
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.value && !containerRef.value.contains(event.target as Node)) {
isOpen.value = false
}
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isOpen.value) {
isOpen.value = false
}
}
// Sync local state with props
watch(() => props.startDate, (val) => {
localStartDate.value = val
onDateChange()
})
watch(() => props.endDate, (val) => {
localEndDate.value = val
onDateChange()
})
onMounted(() => {
document.addEventListener('click', handleClickOutside)
document.addEventListener('keydown', handleEscape)
// Initialize active preset detection
onDateChange()
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
document.removeEventListener('keydown', handleEscape)
})
</script>
<style scoped>
.date-picker-trigger {
@apply flex items-center gap-2;
@apply px-3 py-2 rounded-lg 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 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;
}
.date-picker-icon {
@apply text-gray-400 dark:text-dark-400;
}
.date-picker-value {
@apply font-medium;
}
.date-picker-chevron {
@apply text-gray-400 dark:text-dark-400;
}
.date-picker-dropdown {
@apply absolute z-[100] mt-2 left-0;
@apply bg-white dark:bg-dark-800;
@apply rounded-xl;
@apply border border-gray-200 dark:border-dark-700;
@apply shadow-lg shadow-black/10 dark:shadow-black/30;
@apply overflow-hidden;
@apply min-w-[320px];
}
.date-picker-presets {
@apply grid grid-cols-2 gap-1 p-2;
}
.date-picker-preset {
@apply px-3 py-1.5 text-xs font-medium rounded-md;
@apply text-gray-600 dark:text-gray-400;
@apply hover:bg-gray-100 dark:hover:bg-dark-700;
@apply transition-colors duration-150;
}
.date-picker-preset-active {
@apply bg-primary-100 dark:bg-primary-900/30;
@apply text-primary-700 dark:text-primary-300;
}
.date-picker-divider {
@apply border-t border-gray-100 dark:border-dark-700;
}
.date-picker-custom {
@apply flex items-end gap-2 p-3;
}
.date-picker-field {
@apply flex-1;
}
.date-picker-label {
@apply block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1;
}
.date-picker-input {
@apply w-full px-2 py-1.5 text-sm rounded-md;
@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;
}
.date-picker-input::-webkit-calendar-picker-indicator {
@apply cursor-pointer opacity-60 hover:opacity-100;
filter: invert(0.5);
}
.dark .date-picker-input::-webkit-calendar-picker-indicator {
filter: invert(0.7);
}
.date-picker-separator {
@apply flex items-center justify-center pb-1;
}
.date-picker-actions {
@apply flex justify-end p-2 pt-0;
}
.date-picker-apply {
@apply px-4 py-1.5 text-sm font-medium rounded-lg;
@apply bg-primary-600 text-white;
@apply hover:bg-primary-700;
@apply transition-colors duration-150;
}
/* Dropdown animation */
.date-picker-dropdown-enter-active,
.date-picker-dropdown-leave-active {
transition: all 0.2s ease;
}
.date-picker-dropdown-enter-from,
.date-picker-dropdown-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>

View File

@@ -0,0 +1,91 @@
<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">
<slot name="icon">
<component
v-if="icon"
:is="icon"
class="empty-state-icon w-10 h-10"
aria-hidden="true"
/>
<svg
v-else
class="empty-state-icon w-10 h-10"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
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>
</slot>
</div>
<!-- Title -->
<h3 class="empty-state-title">
{{ title }}
</h3>
<!-- Description -->
<p class="empty-state-description">
{{ description }}
</p>
<!-- Action -->
<div v-if="actionText || $slots.action" class="mt-6">
<slot name="action">
<component
:is="actionTo ? 'RouterLink' : 'button'"
v-if="actionText"
:to="actionTo"
@click="!actionTo && $emit('action')"
class="btn btn-primary"
>
<svg
v-if="actionIcon"
class="w-5 h-5 mr-2"
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"
/>
</svg>
{{ actionText }}
</component>
</slot>
</div>
</div>
</template>
<script setup lang="ts">
import type { Component } from 'vue'
import { RouterLink } from 'vue-router'
interface Props {
icon?: Component | string
title?: string
description?: string
actionText?: string
actionTo?: string | object
actionIcon?: boolean
message?: string
}
const props = withDefaults(defineProps<Props>(), {
title: 'No data found',
description: '',
actionIcon: true
})
defineEmits(['action'])
</script>

View File

@@ -0,0 +1,50 @@
<template>
<span
:class="[
'inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md text-xs font-medium transition-colors',
isSubscription
? 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400'
: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
]"
>
<!-- Subscription type icon (calendar) -->
<svg v-if="isSubscription" class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<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>
<!-- Standard type icon (wallet) -->
<svg v-else class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3" />
</svg>
<span class="truncate">{{ name }}</span>
<span
v-if="showRate && rateMultiplier !== undefined"
:class="[
'px-1 py-0.5 rounded text-[10px] font-semibold',
isSubscription
? 'bg-violet-200/60 text-violet-800 dark:bg-violet-800/40 dark:text-violet-300'
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
]"
>
{{ rateMultiplier }}x
</span>
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { SubscriptionType } from '@/types'
interface Props {
name: string
subscriptionType?: SubscriptionType
rateMultiplier?: number
showRate?: boolean
}
const props = withDefaults(defineProps<Props>(), {
subscriptionType: 'standard',
showRate: true
})
const isSubscription = computed(() => props.subscriptionType === 'subscription')
</script>

View File

@@ -0,0 +1,61 @@
<template>
<div>
<label class="input-label">
Groups
<span class="text-gray-400 font-normal">({{ 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"
>
<label
v-for="group in groups"
: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"
:title="`${group.rate_multiplier}x rate · ${group.account_count || 0} accounts`"
>
<input
type="checkbox"
: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"
/>
<GroupBadge
:name="group.name"
:subscription-type="group.subscription_type"
:rate-multiplier="group.rate_multiplier"
class="flex-1 min-w-0"
/>
<span class="text-xs text-gray-400 shrink-0">{{ group.account_count || 0 }}</span>
</label>
<div
v-if="groups.length === 0"
class="col-span-2 text-center text-sm text-gray-500 dark:text-gray-400 py-2"
>
No groups available
</div>
</div>
</div>
</template>
<script setup lang="ts">
import GroupBadge from './GroupBadge.vue'
import type { Group } from '@/types'
interface Props {
modelValue: number[]
groups: Group[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: number[]]
}>()
const handleChange = (groupId: number, checked: boolean) => {
const newValue = checked
? [...props.modelValue, groupId]
: props.modelValue.filter(id => id !== groupId)
emit('update:modelValue', newValue)
}
</script>

View File

@@ -0,0 +1,65 @@
<template>
<div
:class="['spinner', sizeClasses, colorClass]"
role="status"
:aria-label="t('common.loading')"
>
<span class="sr-only">{{ t('common.loading') }}</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
type SpinnerSize = 'sm' | 'md' | 'lg' | 'xl'
type SpinnerColor = 'primary' | 'secondary' | 'white' | 'gray'
interface Props {
size?: SpinnerSize
color?: SpinnerColor
}
const props = withDefaults(defineProps<Props>(), {
size: 'md',
color: 'primary'
})
const sizeClasses = computed(() => {
const sizes: Record<SpinnerSize, string> = {
sm: 'w-4 h-4 border-2',
md: 'w-8 h-8 border-2',
lg: 'w-12 h-12 border-[3px]',
xl: 'w-16 h-16 border-4'
}
return sizes[props.size]
})
const colorClass = computed(() => {
const colors: Record<SpinnerColor, string> = {
primary: 'text-primary-500',
secondary: 'text-gray-500 dark:text-dark-400',
white: 'text-white',
gray: 'text-gray-400 dark:text-dark-500'
}
return colors[props.color]
})
</script>
<style scoped>
.spinner {
@apply inline-block rounded-full border-solid border-current border-r-transparent;
animation: spin 0.75s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,100 @@
<template>
<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"
: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="{ 'rotate-180': isOpen }"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button>
<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"
>
<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 }"
>
<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"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</button>
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { setLocale, availableLocales } from '@/i18n'
const { locale } = useI18n()
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))
function toggleDropdown() {
isOpen.value = !isOpen.value
}
function selectLocale(code: string) {
setLocale(code)
isOpen.value = false
}
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) {
isOpen.value = false
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>
<style scoped>
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.15s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: scale(0.95) translateY(-4px);
}
</style>

View File

@@ -0,0 +1,122 @@
<template>
<Teleport to="body">
<div
v-if="show"
class="modal-overlay"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true"
@click.self="handleClose"
>
<!-- Modal panel -->
<div
:class="['modal-content', sizeClasses]"
@click.stop
>
<!-- Header -->
<div class="modal-header">
<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"
aria-label="Close modal"
>
<svg class="w-5 h-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>
</div>
<!-- Body -->
<div class="modal-body">
<slot></slot>
</div>
<!-- Footer -->
<div
v-if="$slots.footer"
class="modal-footer"
>
<slot name="footer"></slot>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, watch, onMounted, onUnmounted } from 'vue'
type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'full'
interface Props {
show: boolean
title: string
size?: ModalSize
closeOnEscape?: boolean
closeOnClickOutside?: boolean
}
interface Emits {
(e: 'close'): void
}
const props = withDefaults(defineProps<Props>(), {
size: 'md',
closeOnEscape: true,
closeOnClickOutside: false
})
const emit = defineEmits<Emits>()
const sizeClasses = computed(() => {
const sizes: Record<ModalSize, string> = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
full: 'max-w-4xl'
}
return sizes[props.size]
})
const handleClose = () => {
if (props.closeOnClickOutside) {
emit('close')
}
}
const handleEscape = (event: KeyboardEvent) => {
if (props.show && props.closeOnEscape && event.key === 'Escape') {
emit('close')
}
}
// Prevent body scroll when modal is open
watch(
() => props.show,
(isOpen) => {
console.log('[Modal] show changed to:', isOpen)
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
},
{ immediate: true }
)
onMounted(() => {
document.addEventListener('keydown', handleEscape)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleEscape)
document.body.style.overflow = ''
})
</script>

View File

@@ -0,0 +1,214 @@
<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">
<!-- 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"
>
{{ t('pagination.previous') }}
</button>
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ t('pagination.pageOf', { page, total: totalPages }) }}
</span>
<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"
>
{{ t('pagination.next') }}
</button>
</div>
<div class="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<!-- Desktop pagination info -->
<div class="flex items-center space-x-4">
<p class="text-sm text-gray-700 dark:text-gray-300">
{{ t('pagination.showing') }}
<span class="font-medium">{{ fromItem }}</span>
{{ t('pagination.to') }}
<span class="font-medium">{{ toItem }}</span>
{{ t('pagination.of') }}
<span class="font-medium">{{ total }}</span>
{{ t('pagination.results') }}
</p>
<!-- 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">
<Select
:model-value="pageSize"
:options="pageSizeSelectOptions"
@update:model-value="handlePageSizeChange"
/>
</div>
</div>
</div>
<!-- Desktop pagination buttons -->
<nav
class="relative z-0 inline-flex -space-x-px rounded-md shadow-sm"
aria-label="Pagination"
>
<!-- Previous button -->
<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"
:aria-label="t('pagination.previous')"
>
<svg class="w-5 h-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"
clip-rule="evenodd"
/>
</svg>
</button>
<!-- Page numbers -->
<button
v-for="pageNum in visiblePages"
:key="pageNum"
@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',
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',
typeof pageNum !== 'number' && 'cursor-default'
]"
:aria-label="typeof pageNum === 'number' ? t('pagination.goToPage', { page: pageNum }) : undefined"
:aria-current="pageNum === page ? 'page' : undefined"
>
{{ pageNum }}
</button>
<!-- Next button -->
<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"
:aria-label="t('pagination.next')"
>
<svg class="w-5 h-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"
clip-rule="evenodd"
/>
</svg>
</button>
</nav>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Select from './Select.vue'
const { t } = useI18n()
interface Props {
total: number
page: number
pageSize: number
pageSizeOptions?: number[]
}
interface Emits {
(e: 'update:page', page: number): void
(e: 'update:pageSize', pageSize: number): void
}
const props = withDefaults(defineProps<Props>(), {
pageSizeOptions: () => [10, 20, 50, 100]
})
const emit = defineEmits<Emits>()
const totalPages = computed(() => Math.ceil(props.total / props.pageSize))
const fromItem = computed(() => {
if (props.total === 0) return 0
return (props.page - 1) * props.pageSize + 1
})
const toItem = computed(() => {
const to = props.page * props.pageSize
return to > props.total ? props.total : to
})
const pageSizeSelectOptions = computed(() => {
return props.pageSizeOptions.map(size => ({
value: size,
label: String(size)
}))
})
const visiblePages = computed(() => {
const pages: (number | string)[] = []
const maxVisible = 7
const total = totalPages.value
if (total <= maxVisible) {
// Show all pages if total is small
for (let i = 1; i <= total; i++) {
pages.push(i)
}
} else {
// Always show first page
pages.push(1)
const start = Math.max(2, props.page - 2)
const end = Math.min(total - 1, props.page + 2)
// Add ellipsis before if needed
if (start > 2) {
pages.push('...')
}
// Add middle pages
for (let i = start; i <= end; i++) {
pages.push(i)
}
// Add ellipsis after if needed
if (end < total - 1) {
pages.push('...')
}
// Always show last page
pages.push(total)
}
return pages
})
const goToPage = (newPage: number) => {
if (newPage >= 1 && newPage <= totalPages.value && newPage !== props.page) {
emit('update:page', newPage)
}
}
const handlePageSizeChange = (value: string | number | null) => {
if (value === null) return
const newPageSize = typeof value === 'string' ? parseInt(value) : value
emit('update:pageSize', newPageSize)
// Reset to first page when page size changes
if (props.page !== 1) {
emit('update:page', 1)
}
}
</script>
<style scoped>
.page-size-select :deep(.select-trigger) {
@apply py-1.5 px-3 text-sm;
}
</style>

View File

@@ -0,0 +1,426 @@
<template>
<div class="relative" ref="containerRef">
<button
type="button"
@click="toggle"
:disabled="disabled"
:class="[
'select-trigger',
isOpen && 'select-trigger-open',
disabled && 'select-trigger-disabled'
]"
>
<span class="select-value">
{{ selectedLabel }}
</span>
<span class="select-icon">
<svg
:class="['w-5 h-5 transition-transform duration-200', isOpen && 'rotate-180']"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</span>
</button>
<Transition name="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>
<input
ref="searchInputRef"
v-model="searchQuery"
type="text"
:placeholder="t('admin.proxies.searchProxies')"
class="select-search-input"
@click.stop
/>
</div>
<button
v-if="proxies.length > 0"
type="button"
@click.stop="handleBatchTest"
:disabled="batchTesting"
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>
<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>
</button>
</div>
<!-- Options list -->
<div class="select-options">
<!-- No Proxy option -->
<div
@click="selectOption(null)"
: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"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</div>
<!-- Proxy options -->
<div
v-for="proxy in filteredProxies"
:key="proxy.id"
@click="selectOption(proxy.id)"
:class="[
'select-option',
modelValue === proxy.id && 'select-option-selected'
]"
>
<div class="flex-1 min-w-0">
<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"
>
{{ proxy.account_count }}
</span>
<!-- Test result badges -->
<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"
>
<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"
>
{{ t('admin.proxies.testFailed') }}
</span>
</template>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 truncate">
{{ proxy.protocol }}://{{ proxy.host }}:{{ proxy.port }}
</div>
</div>
<!-- Individual test button -->
<button
type="button"
@click.stop="handleTestProxy(proxy)"
:disabled="testingProxyIds.has(proxy.id)"
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>
<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>
</button>
<svg
v-if="modelValue === proxy.id"
class="w-4 h-4 text-primary-500 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</div>
<!-- Empty state -->
<div v-if="filteredProxies.length === 0 && searchQuery" class="select-empty">
{{ t('common.noOptionsFound') }}
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin'
import type { Proxy } from '@/types'
const { t } = useI18n()
interface ProxyTestResult {
success: boolean
message: string
latency_ms?: number
ip_address?: string
city?: string
region?: string
country?: string
}
interface Props {
modelValue: number | null
proxies: Proxy[]
disabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
})
const emit = defineEmits<{
'update:modelValue': [value: number | null]
}>()
const isOpen = ref(false)
const searchQuery = ref('')
const containerRef = ref<HTMLElement | null>(null)
const searchInputRef = ref<HTMLInputElement | null>(null)
// Test state
const testResults = reactive<Record<number, ProxyTestResult>>({})
const testingProxyIds = reactive(new Set<number>())
const batchTesting = ref(false)
const selectedProxy = computed(() => {
if (props.modelValue === null) return null
return props.proxies.find(p => p.id === props.modelValue) || null
})
const selectedLabel = computed(() => {
if (!selectedProxy.value) {
return t('admin.accounts.noProxy')
}
const proxy = selectedProxy.value
return `${proxy.name} (${proxy.protocol}://${proxy.host}:${proxy.port})`
})
const filteredProxies = computed(() => {
if (!searchQuery.value) {
return props.proxies
}
const query = searchQuery.value.toLowerCase()
return props.proxies.filter(proxy => {
const name = proxy.name.toLowerCase()
const host = proxy.host.toLowerCase()
return name.includes(query) || host.includes(query)
})
})
const toggle = () => {
if (props.disabled) return
isOpen.value = !isOpen.value
if (isOpen.value) {
nextTick(() => {
searchInputRef.value?.focus()
})
}
}
const selectOption = (value: number | null) => {
emit('update:modelValue', value)
isOpen.value = false
searchQuery.value = ''
}
const handleTestProxy = async (proxy: Proxy) => {
if (testingProxyIds.has(proxy.id)) return
testingProxyIds.add(proxy.id)
try {
const result = await adminAPI.proxies.testProxy(proxy.id)
testResults[proxy.id] = result
} catch (error: any) {
testResults[proxy.id] = {
success: false,
message: error.response?.data?.detail || 'Test failed'
}
} finally {
testingProxyIds.delete(proxy.id)
}
}
const handleBatchTest = async () => {
if (batchTesting.value || props.proxies.length === 0) return
batchTesting.value = true
// Test all proxies in parallel
const testPromises = props.proxies.map(async (proxy) => {
testingProxyIds.add(proxy.id)
try {
const result = await adminAPI.proxies.testProxy(proxy.id)
testResults[proxy.id] = result
} catch (error: any) {
testResults[proxy.id] = {
success: false,
message: error.response?.data?.detail || 'Test failed'
}
} finally {
testingProxyIds.delete(proxy.id)
}
})
await Promise.all(testPromises)
batchTesting.value = false
}
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.value && !containerRef.value.contains(event.target as Node)) {
isOpen.value = false
searchQuery.value = ''
}
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isOpen.value) {
isOpen.value = false
searchQuery.value = ''
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
document.addEventListener('keydown', handleEscape)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
document.removeEventListener('keydown', handleEscape)
})
</script>
<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 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 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;
}
.select-trigger-disabled {
@apply bg-gray-100 dark:bg-dark-900 cursor-not-allowed opacity-60;
}
.select-value {
@apply flex-1 text-left truncate;
}
.select-icon {
@apply flex-shrink-0 text-gray-400 dark:text-dark-400;
}
.select-dropdown {
@apply absolute z-[100] w-full mt-2;
@apply bg-white dark:bg-dark-800;
@apply rounded-xl;
@apply border border-gray-200 dark:border-dark-700;
@apply shadow-lg shadow-black/10 dark:shadow-black/30;
@apply overflow-hidden;
}
.select-header {
@apply flex items-center gap-2 px-3 py-2;
@apply border-b border-gray-100 dark:border-dark-700;
}
.select-search {
@apply flex-1 flex items-center gap-2;
}
.select-search-input {
@apply flex-1 bg-transparent text-sm;
@apply text-gray-900 dark:text-gray-100;
@apply placeholder:text-gray-400 dark:placeholder:text-dark-400;
@apply focus:outline-none;
}
.batch-test-btn {
@apply flex-shrink-0 p-1.5 rounded-lg;
@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;
}
.select-options {
@apply max-h-60 overflow-y-auto py-1;
}
.select-option {
@apply flex items-center justify-between gap-2;
@apply px-4 py-2.5 text-sm;
@apply text-gray-700 dark:text-gray-300;
@apply cursor-pointer transition-colors duration-150;
@apply hover:bg-gray-50 dark:hover:bg-dark-700;
}
.select-option-selected {
@apply bg-primary-50 dark:bg-primary-900/20;
@apply text-primary-700 dark:text-primary-300;
}
.select-option-label {
@apply truncate;
}
.select-empty {
@apply px-4 py-8 text-center text-sm;
@apply text-gray-500 dark:text-dark-400;
}
.test-btn {
@apply flex-shrink-0 p-1 rounded;
@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;
}
/* Dropdown animation */
.select-dropdown-enter-active,
.select-dropdown-leave-active {
transition: all 0.2s ease;
}
.select-dropdown-enter-from,
.select-dropdown-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>

View File

@@ -0,0 +1,243 @@
# Common Components
This directory contains reusable Vue 3 components built with Composition API, TypeScript, and TailwindCSS.
## 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="[
{ key: 'name', label: 'Name', sortable: true },
{ key: 'email', label: 'Email' },
{ key: 'status', label: 'Status', formatter: (val) => val.toUpperCase() }
]"
:data="users"
:loading="isLoading"
>
<template #cell-actions="{ row }">
<button @click="editUser(row)">Edit</button>
</template>
</DataTable>
```
---
### 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"
:page="currentPage"
:pageSize="pageSize"
@update:page="currentPage = $event"
@update:pageSize="pageSize = $event"
/>
```
---
### 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')
- `closeOnEscape?: boolean` - Close on Escape key (default: true)
- `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">
<!-- Form content -->
</form>
<template #footer>
<button @click="showModal = false">Cancel</button>
<button @click="saveUser">Save</button>
</template>
</Modal>
```
---
### ConfirmDialog.vue
Confirmation dialog built on top of Modal component.
**Props:**
- `show: boolean` - Control dialog visibility
- `title: string` - Dialog title
- `message: string` - Confirmation message
- `confirmText?: string` - Confirm button text (default: 'Confirm')
- `cancelText?: string` - Cancel button text (default: 'Cancel')
- `danger?: boolean` - Use danger/red styling (default: false)
**Events:**
- `confirm` - Emitted when user confirms
- `cancel` - Emitted when user cancels
**Usage:**
```vue
<ConfirmDialog
:show="showDeleteConfirm"
title="Delete User"
message="Are you sure you want to delete this user? This action cannot be undone."
confirm-text="Delete"
cancel-text="Cancel"
danger
@confirm="deleteUser"
@cancel="showDeleteConfirm = false"
/>
```
---
### 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
- `change?: number` - Percentage change value
- `changeType?: 'up' | 'down' | 'neutral'` - Change direction (default: 'neutral')
- `formatValue?: (value) => string` - Custom value formatter
**Usage:**
```vue
<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 />
```
```typescript
// Trigger toasts from anywhere using the app store
import { useAppStore } from '@/stores/app'
const appStore = useAppStore()
appStore.addToast({
type: 'success',
title: 'Success!',
message: 'User created successfully',
duration: 3000
})
appStore.addToast({
type: 'error',
message: 'Failed to delete user'
})
```
---
### 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" />
```
---
### 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
- `actionText?: string` - Action button text
- `actionTo?: string | object` - Router link destination
- `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"
description="Get started by creating your first user account."
action-text="Add User"
:action-to="{ name: 'users-create' }"
/>
```
## Import
You can import components individually:
```typescript
import { DataTable, Pagination, Modal } from '@/components/common'
```
Or import specific components:
```typescript
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
- **TailwindCSS styling** for consistent design
- **Vue 3 Composition API** with `<script setup>`
- **Slot support** for customization

View File

@@ -0,0 +1,319 @@
<template>
<div class="relative" ref="containerRef">
<button
type="button"
@click="toggle"
:disabled="disabled"
:class="[
'select-trigger',
isOpen && 'select-trigger-open',
error && 'select-trigger-error',
disabled && 'select-trigger-disabled'
]"
>
<span class="select-value">
<slot name="selected" :option="selectedOption">
{{ selectedLabel }}
</slot>
</span>
<span class="select-icon">
<svg
:class="['w-5 h-5 transition-transform duration-200', isOpen && 'rotate-180']"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</span>
</button>
<Transition name="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>
<input
ref="searchInputRef"
v-model="searchQuery"
type="text"
:placeholder="searchPlaceholderText"
class="select-search-input"
@click.stop
/>
</div>
<!-- Options list -->
<div class="select-options">
<div
v-for="option in filteredOptions"
:key="getOptionValue(option)"
@click="selectOption(option)"
: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"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</slot>
</div>
<!-- Empty state -->
<div v-if="filteredOptions.length === 0" class="select-empty">
{{ emptyTextDisplay }}
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
export interface SelectOption {
value: string | number | null
label: string
disabled?: boolean
[key: string]: unknown
}
interface Props {
modelValue: string | number | null | undefined
options: SelectOption[] | Array<Record<string, unknown>>
placeholder?: string
disabled?: boolean
error?: boolean
searchable?: boolean
searchPlaceholder?: string
emptyText?: string
valueKey?: string
labelKey?: string
}
interface Emits {
(e: 'update:modelValue', value: string | number | null): void
(e: 'change', value: string | number | null, option: SelectOption | null): void
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
error: false,
searchable: false,
valueKey: 'value',
labelKey: 'label'
})
// Use computed for i18n default values
const placeholderText = computed(() => props.placeholder ?? t('common.selectOption'))
const searchPlaceholderText = computed(() => props.searchPlaceholder ?? t('common.searchPlaceholder'))
const emptyTextDisplay = computed(() => props.emptyText ?? t('common.noOptionsFound'))
const emit = defineEmits<Emits>()
const isOpen = ref(false)
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 => {
if (typeof option === 'object' && option !== null) {
return option[props.valueKey] as string | number | null
}
return option as string | number | null
}
const getOptionLabel = (option: SelectOption | Record<string, unknown>): string => {
if (typeof option === 'object' && option !== null) {
return String(option[props.labelKey] ?? '')
}
return String(option ?? '')
}
const selectedOption = computed(() => {
return props.options.find(opt => getOptionValue(opt) === props.modelValue) || null
})
const selectedLabel = computed(() => {
if (selectedOption.value) {
return getOptionLabel(selectedOption.value)
}
return placeholderText.value
})
const filteredOptions = computed(() => {
if (!props.searchable || !searchQuery.value) {
return props.options
}
const query = searchQuery.value.toLowerCase()
return props.options.filter(opt => {
const label = getOptionLabel(opt).toLowerCase()
return label.includes(query)
})
})
const isSelected = (option: SelectOption | Record<string, unknown>): boolean => {
return getOptionValue(option) === props.modelValue
}
const toggle = () => {
if (props.disabled) return
isOpen.value = !isOpen.value
if (isOpen.value && props.searchable) {
nextTick(() => {
searchInputRef.value?.focus()
})
}
}
const selectOption = (option: SelectOption | Record<string, unknown>) => {
const value = getOptionValue(option)
emit('update:modelValue', value)
emit('change', value, option as SelectOption)
isOpen.value = false
searchQuery.value = ''
}
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.value && !containerRef.value.contains(event.target as Node)) {
isOpen.value = false
searchQuery.value = ''
}
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isOpen.value) {
isOpen.value = false
searchQuery.value = ''
}
}
watch(isOpen, (open) => {
if (!open) {
searchQuery.value = ''
}
})
onMounted(() => {
document.addEventListener('click', handleClickOutside)
document.addEventListener('keydown', handleEscape)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
document.removeEventListener('keydown', handleEscape)
})
</script>
<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 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 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;
}
.select-trigger-error {
@apply border-red-500 focus:ring-red-500/30 focus:border-red-500;
}
.select-trigger-disabled {
@apply bg-gray-100 dark:bg-dark-900 cursor-not-allowed opacity-60;
}
.select-value {
@apply flex-1 text-left truncate;
}
.select-icon {
@apply flex-shrink-0 text-gray-400 dark:text-dark-400;
}
.select-dropdown {
@apply absolute z-[100] w-full mt-2;
@apply bg-white dark:bg-dark-800;
@apply rounded-xl;
@apply border border-gray-200 dark:border-dark-700;
@apply shadow-lg shadow-black/10 dark:shadow-black/30;
@apply overflow-hidden;
}
.select-search {
@apply flex items-center gap-2 px-3 py-2;
@apply border-b border-gray-100 dark:border-dark-700;
}
.select-search-input {
@apply flex-1 bg-transparent text-sm;
@apply text-gray-900 dark:text-gray-100;
@apply placeholder:text-gray-400 dark:placeholder:text-dark-400;
@apply focus:outline-none;
}
.select-options {
@apply max-h-60 overflow-y-auto py-1;
}
.select-option {
@apply flex items-center justify-between gap-2;
@apply px-4 py-2.5 text-sm;
@apply text-gray-700 dark:text-gray-300;
@apply cursor-pointer transition-colors duration-150;
@apply hover:bg-gray-50 dark:hover:bg-dark-700;
}
.select-option-selected {
@apply bg-primary-50 dark:bg-primary-900/20;
@apply text-primary-700 dark:text-primary-300;
}
.select-option-label {
@apply truncate;
}
.select-empty {
@apply px-4 py-8 text-center text-sm;
@apply text-gray-500 dark:text-dark-400;
}
/* Dropdown animation */
.select-dropdown-enter-active,
.select-dropdown-leave-active {
transition: all 0.2s ease;
}
.select-dropdown-enter-from,
.select-dropdown-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>

View File

@@ -0,0 +1,94 @@
<template>
<div class="stat-card">
<div :class="['stat-icon', iconClass]">
<component
v-if="icon"
:is="icon"
class="w-6 h-6"
aria-hidden="true"
/>
</div>
<div class="flex-1 min-w-0">
<p class="stat-label truncate">{{ title }}</p>
<div class="flex items-baseline gap-2 mt-1">
<p class="stat-value">{{ formattedValue }}</p>
<span
v-if="change !== undefined"
:class="['stat-trend', trendClass]"
>
<svg
v-if="changeType !== 'neutral'"
:class="['w-3 h-3', changeType === 'down' && 'rotate-180']"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z"
clip-rule="evenodd"
/>
</svg>
{{ formattedChange }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Component } from 'vue'
type ChangeType = 'up' | 'down' | 'neutral'
type IconVariant = 'primary' | 'success' | 'warning' | 'danger'
interface Props {
title: string
value: number | string
icon?: Component
iconVariant?: IconVariant
change?: number
changeType?: ChangeType
formatValue?: (value: number | string) => string
}
const props = withDefaults(defineProps<Props>(), {
changeType: 'neutral',
iconVariant: 'primary'
})
const formattedValue = computed(() => {
if (props.formatValue) {
return props.formatValue(props.value)
}
if (typeof props.value === 'number') {
return props.value.toLocaleString()
}
return props.value
})
const formattedChange = computed(() => {
if (props.change === undefined) return ''
const absChange = Math.abs(props.change)
return `${absChange}%`
})
const iconClass = computed(() => {
const classes: Record<IconVariant, string> = {
primary: 'stat-icon-primary',
success: 'stat-icon-success',
warning: 'stat-icon-warning',
danger: 'stat-icon-danger'
}
return classes[props.iconVariant]
})
const trendClass = computed(() => {
const classes: Record<ChangeType, string> = {
up: 'stat-trend-up',
down: 'stat-trend-down',
neutral: 'text-gray-500 dark:text-dark-400'
}
return classes[props.changeType]
})
</script>

View File

@@ -0,0 +1,267 @@
<template>
<div v-if="hasActiveSubscriptions" class="relative" ref="containerRef">
<!-- 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"
: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>
<div class="flex items-center gap-1.5">
<!-- Combined progress indicator -->
<div class="flex items-center gap-0.5">
<div
v-for="(sub, index) in displaySubscriptions.slice(0, 3)"
:key="index"
class="w-2 h-2 rounded-full"
:class="getProgressDotClass(sub)"
></div>
</div>
<span class="text-xs font-medium text-purple-700 dark:text-purple-300">
{{ activeSubscriptions.length }}
</span>
</div>
</button>
<!-- Hover/Click Tooltip -->
<transition name="dropdown">
<div
v-if="tooltipOpen"
class="absolute right-0 mt-2 w-80 bg-white dark:bg-dark-800 rounded-xl shadow-xl border border-gray-200 dark:border-dark-700 z-50"
>
<div class="p-3 border-b border-gray-100 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">
{{ t('subscriptionProgress.activeCount', { count: activeSubscriptions.length }) }}
</p>
</div>
<div class="max-h-64 overflow-y-auto">
<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"
>
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-900 dark:text-white">
{{ subscription.group?.name || `Group #${subscription.group_id}` }}
</span>
<span
v-if="subscription.expires_at"
class="text-xs"
:class="getDaysRemainingClass(subscription.expires_at)"
>
{{ formatDaysRemaining(subscription.expires_at) }}
</span>
</div>
<!-- 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">{{ t('subscriptionProgress.daily') }}</span>
<div class="flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
<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) }"
></div>
</div>
<span class="text-[10px] text-gray-500 w-16 text-right">
{{ 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">{{ t('subscriptionProgress.weekly') }}</span>
<div class="flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
<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) }"
></div>
</div>
<span class="text-[10px] text-gray-500 w-16 text-right">
{{ 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">{{ t('subscriptionProgress.monthly') }}</span>
<div class="flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
<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) }"
></div>
</div>
<span class="text-[10px] text-gray-500 w-16 text-right">
{{ 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">
<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"
>
{{ t('subscriptionProgress.viewAll') }}
</router-link>
</div>
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import subscriptionsAPI from '@/api/subscriptions';
import type { UserSubscription } from '@/types';
const { t } = useI18n();
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 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;
});
});
function getMaxUsagePercentage(sub: UserSubscription): number {
const percentages: number[] = [];
if (sub.group?.daily_limit_usd) {
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);
}
if (sub.group?.monthly_limit_usd) {
percentages.push((sub.monthly_usage_usd || 0) / sub.group.monthly_limit_usd * 100);
}
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';
}
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';
}
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}%`;
}
function formatUsage(used: number | undefined, limit: number | null | undefined): string {
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 });
}
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';
}
function toggleTooltip() {
tooltipOpen.value = !tooltipOpen.value;
}
function closeTooltip() {
tooltipOpen.value = false;
}
function handleClickOutside(event: MouseEvent) {
if (containerRef.value && !containerRef.value.contains(event.target as Node)) {
closeTooltip();
}
}
async function loadSubscriptions() {
try {
loading.value = true;
activeSubscriptions.value = await subscriptionsAPI.getActiveSubscriptions();
} catch (error) {
console.error('Failed to load subscriptions:', error);
activeSubscriptions.value = [];
} finally {
loading.value = false;
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside);
loadSubscriptions();
});
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside);
});
// Refresh subscriptions periodically (every 5 minutes)
let refreshInterval: ReturnType<typeof setInterval> | null = null;
onMounted(() => {
refreshInterval = setInterval(loadSubscriptions, 5 * 60 * 1000);
});
onBeforeUnmount(() => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
});
</script>
<style scoped>
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.2s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: scale(0.95) translateY(-4px);
}
</style>

View File

@@ -0,0 +1,224 @@
<template>
<Teleport to="body">
<div
class="fixed top-4 right-4 z-[9999] space-y-3 pointer-events-none"
aria-live="polite"
aria-atomic="true"
>
<TransitionGroup
enter-active-class="transition ease-out duration-300"
enter-from-class="opacity-0 translate-x-full"
enter-to-class="opacity-100 translate-x-0"
leave-active-class="transition ease-in duration-200"
leave-from-class="opacity-100 translate-x-0"
leave-to-class="opacity-0 translate-x-full"
>
<div
v-for="toast in toasts"
:key="toast.id"
:class="[
'pointer-events-auto min-w-[320px] max-w-md overflow-hidden rounded-lg shadow-lg',
'bg-white dark:bg-dark-800',
'border-l-4',
getBorderColor(toast.type)
]"
>
<div class="p-4">
<div class="flex items-start gap-3">
<!-- Icon -->
<div class="flex-shrink-0 mt-0.5">
<component
:is="getIcon(toast.type)"
:class="['w-5 h-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"
>
{{ 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.message }}
</p>
</div>
<!-- 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"
aria-label="Close notification"
>
<svg class="w-4 h-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"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
</div>
<!-- Progress bar -->
<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)}%` }"
></div>
</div>
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, h } from 'vue'
import { useAppStore } from '@/stores/app'
const appStore = useAppStore()
const toasts = computed(() => appStore.toasts)
const getIcon = (type: string) => {
const icons = {
success: () =>
h(
'svg',
{
fill: 'currentColor',
viewBox: '0 0 20 20'
},
[
h('path', {
'fill-rule': 'evenodd',
d: 'M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z',
'clip-rule': 'evenodd'
})
]
),
error: () =>
h(
'svg',
{
fill: 'currentColor',
viewBox: '0 0 20 20'
},
[
h('path', {
'fill-rule': 'evenodd',
d: 'M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z',
'clip-rule': 'evenodd'
})
]
),
warning: () =>
h(
'svg',
{
fill: 'currentColor',
viewBox: '0 0 20 20'
},
[
h('path', {
'fill-rule': 'evenodd',
d: 'M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z',
'clip-rule': 'evenodd'
})
]
),
info: () =>
h(
'svg',
{
fill: 'currentColor',
viewBox: '0 0 20 20'
},
[
h('path', {
'fill-rule': 'evenodd',
d: 'M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z',
'clip-rule': 'evenodd'
})
]
)
}
return icons[type as keyof typeof icons] || icons.info
}
const getIconColor = (type: string): string => {
const colors: Record<string, string> = {
success: 'text-green-500',
error: 'text-red-500',
warning: 'text-yellow-500',
info: 'text-blue-500'
}
return colors[type] || colors.info
}
const getBorderColor = (type: string): string => {
const colors: Record<string, string> = {
success: 'border-green-500',
error: 'border-red-500',
warning: 'border-yellow-500',
info: 'border-blue-500'
}
return colors[type] || colors.info
}
const getProgressBarColor = (type: string): string => {
const colors: Record<string, string> = {
success: 'bg-green-500',
error: 'bg-red-500',
warning: 'bg-yellow-500',
info: 'bg-blue-500'
}
return colors[type] || colors.info
}
const getProgress = (toast: any): number => {
if (!toast.duration || !toast.startTime) return 100
const elapsed = Date.now() - toast.startTime
const progress = Math.max(0, 100 - (elapsed / toast.duration) * 100)
return progress
}
const removeToast = (id: string) => {
appStore.hideToast(id)
}
let intervalId: number | undefined
onMounted(() => {
// Check for expired toasts every 100ms
intervalId = window.setInterval(() => {
const now = Date.now()
toasts.value.forEach((toast) => {
if (toast.duration && toast.startTime) {
if (now - toast.startTime >= toast.duration) {
removeToast(toast.id)
}
}
})
}, 100)
})
onUnmounted(() => {
if (intervalId !== undefined) {
clearInterval(intervalId)
}
})
</script>

View File

@@ -0,0 +1,35 @@
<template>
<button
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'
]"
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'
]"
/>
</button>
</template>
<script setup lang="ts">
const props = defineProps<{
modelValue: boolean;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
}>();
function toggle() {
emit('update:modelValue', !props.modelValue);
}
</script>

View File

@@ -0,0 +1,250 @@
<template>
<div class="relative">
<!-- Admin: Full version badge with dropdown -->
<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="[
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'
]"
:title="hasUpdate ? 'New version available' : 'Up to date'"
>
<span class="font-medium">v{{ currentVersion }}</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>
</button>
<!-- Dropdown -->
<transition name="dropdown">
<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"
>
<!-- 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>
<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"
: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>
</button>
</div>
<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>
</div>
<!-- Content -->
<template v-else>
<!-- Version display - centered and prominent -->
<div class="text-center mb-4">
<div class="inline-flex items-center gap-2">
<span class="text-2xl font-bold text-gray-900 dark:text-white">v{{ currentVersion }}</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" />
</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>
</div>
<!-- Update available for source build - show git pull hint -->
<div v-if="hasUpdate && !isReleaseBuild" class="space-y-2">
<a
v-if="releaseInfo?.html_url && releaseInfo.html_url !== '#'"
: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"
>
<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" />
</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>
<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">
<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" />
</svg>
<p class="text-xs text-blue-600 dark:text-blue-400">{{ t('version.sourceModeHint') }}</p>
</div>
</div>
<!-- Update available for release build - show download link -->
<a
v-else-if="hasUpdate && isReleaseBuild && releaseInfo?.html_url && releaseInfo.html_url !== '#'"
: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"
>
<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" />
</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>
<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">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</a>
<!-- GitHub link when up to date -->
<a
v-else-if="releaseInfo?.html_url && releaseInfo.html_url !== '#'"
: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"
>
<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>
{{ t('version.viewRelease') }}
</a>
</template>
</div>
</div>
</transition>
</template>
<!-- Non-admin: Simple static version text -->
<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 } from '@/stores';
import { checkUpdates, type VersionInfo, type ReleaseInfo } from '@/api/admin/system';
const { t } = useI18n();
const props = defineProps<{
version?: string;
}>();
const authStore = useAuthStore();
const isAdmin = computed(() => authStore.isAdmin);
const loading = ref(false);
const dropdownOpen = ref(false);
const dropdownRef = ref<HTMLElement | null>(null);
const currentVersion = ref('0.1.0');
const latestVersion = ref('0.1.0');
const hasUpdate = ref(false);
const releaseInfo = ref<ReleaseInfo | null>(null);
const buildType = ref('source'); // "source" or "release"
// Only show update check for release builds (binary/docker deployment)
const isReleaseBuild = computed(() => buildType.value === 'release');
function toggleDropdown() {
dropdownOpen.value = !dropdownOpen.value;
}
function closeDropdown() {
dropdownOpen.value = false;
}
async function refreshVersion(force = true) {
if (!isAdmin.value) return;
loading.value = true;
try {
const data: VersionInfo = await checkUpdates(force);
currentVersion.value = data.current_version;
latestVersion.value = data.latest_version;
buildType.value = data.build_type || 'source';
// Show update indicator for all build types
hasUpdate.value = data.has_update;
releaseInfo.value = data.release_info || null;
} catch (error) {
console.error('Failed to check updates:', error);
} finally {
loading.value = false;
}
}
function handleClickOutside(event: MouseEvent) {
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();
}
}
onMounted(() => {
if (isAdmin.value) {
refreshVersion(false);
} else if (props.version) {
currentVersion.value = props.version;
}
document.addEventListener('click', handleClickOutside);
});
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside);
});
</script>
<style scoped>
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.2s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: scale(0.95) translateY(-4px);
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,13 @@
// Export all common components
export { default as DataTable } from './DataTable.vue'
export { default as Pagination } from './Pagination.vue'
export { default as Modal } from './Modal.vue'
export { default as ConfirmDialog } from './ConfirmDialog.vue'
export { default as StatCard } from './StatCard.vue'
export { default as Toast } from './Toast.vue'
export { default as LoadingSpinner } from './LoadingSpinner.vue'
export { default as EmptyState } from './EmptyState.vue'
export { default as LocaleSwitcher } from './LocaleSwitcher.vue'
// Export types
export type { Column } from './DataTable.vue'