refactor(frontend): comprehensive split of large view files into modular components

- Split UsersView.vue into UserCreateModal, UserEditModal, UserApiKeysModal, etc.
- Split UsageView.vue into UsageStatsCards, UsageFilters, UsageTable, etc.
- Split DashboardView.vue into UserDashboardStats, UserDashboardCharts, etc.
- Split AccountsView.vue into AccountTableActions, AccountTableFilters, etc.
- Standardized TypeScript types across new components to resolve implicit 'any' and 'never[]' errors.
- Improved overall frontend maintainability and code clarity.
This commit is contained in:
IanShaw027
2026-01-04 22:17:27 +08:00
parent 7122b3b3b6
commit e99063e12b
28 changed files with 1454 additions and 5516 deletions

View File

@@ -0,0 +1,21 @@
<template>
<Teleport to="body">
<div v-if="show && position" class="action-menu-content fixed z-[9999] w-52 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 dark:bg-dark-800" :style="{ top: position.top + 'px', left: position.left + 'px' }">
<div class="py-1">
<template v-if="account">
<button @click="$emit('test', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100"><span class="text-green-500"></span> {{ t('admin.accounts.testConnection') }}</button>
<button @click="$emit('stats', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100"><span class="text-indigo-500">📊</span> {{ t('admin.accounts.viewStats') }}</button>
<template v-if="account.type === 'oauth' || account.type === 'setup-token'">
<button @click="$emit('reauth', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 text-blue-600">🔗 {{ t('admin.accounts.reAuthorize') }}</button>
<button @click="$emit('refresh-token', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 text-purple-600">🔄 {{ t('admin.accounts.refreshToken') }}</button>
</template>
</template>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
defineProps(['show', 'account', 'position']); defineEmits(['close', 'test', 'stats', 'reauth', 'refresh-token']); const { t } = useI18n()
</script>

View File

@@ -0,0 +1,14 @@
<template>
<div v-if="selectedIds.length > 0" class="mb-4 flex items-center justify-between p-3 bg-primary-50 rounded-lg">
<span class="text-sm font-medium">{{ t('admin.accounts.bulkActions.selected', { count: selectedIds.length }) }}</span>
<div class="flex gap-2">
<button @click="$emit('delete')" class="btn btn-danger btn-sm">{{ t('admin.accounts.bulkActions.delete') }}</button>
<button @click="$emit('edit')" class="btn btn-primary btn-sm">{{ t('admin.accounts.bulkActions.edit') }}</button>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
defineProps(['selectedIds']); defineEmits(['delete', 'edit']); const { t } = useI18n()
</script>

View File

@@ -0,0 +1,11 @@
<template>
<div class="flex justify-end gap-3">
<button @click="$emit('refresh')" :disabled="loading" class="btn btn-secondary"><svg :class="['h-5 w-5', loading ? 'animate-spin' : '']" fill="none" viewBox="0 0 24 24" stroke="currentColor"><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>
<button @click="$emit('sync')" class="btn btn-secondary">Sync CRS</button>
<button @click="$emit('create')" class="btn btn-primary">{{ t('admin.accounts.createAccount') }}</button>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'; defineProps(['loading']); defineEmits(['refresh', 'sync', 'create']); const { t } = useI18n()
</script>

View File

@@ -0,0 +1,16 @@
<template>
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div class="relative max-w-md flex-1"><input :value="searchQuery" type="text" :placeholder="t('admin.accounts.searchAccounts')" class="input" @input="$emit('update:searchQuery', ($event.target as HTMLInputElement).value)" /></div>
<div class="flex gap-3">
<Select v-model="filters.platform" :options="pOpts" @change="$emit('change')" />
<Select v-model="filters.status" :options="sOpts" @change="$emit('change')" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import Select from '@/components/common/Select.vue'
defineProps(['searchQuery', 'filters']); defineEmits(['update:searchQuery', 'change']); const { t } = useI18n()
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'openai', label: 'OpenAI' }, { value: 'anthropic', label: 'Anthropic' }, { value: 'gemini', label: 'Gemini' }])
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'error', label: t('admin.accounts.status.error') }])
</script>

View File

@@ -0,0 +1,16 @@
<template>
<ExportProgressDialog
:show="show"
:progress="progress"
:current="current"
:total="total"
:estimated-time="estimatedTime"
@cancel="$emit('cancel')"
/>
</template>
<script setup lang="ts">
import ExportProgressDialog from '@/components/common/ExportProgressDialog.vue'
defineProps<{ show: boolean, progress: number, current: number, total: number, estimatedTime: string }>()
defineEmits(['cancel'])
</script>

View File

@@ -0,0 +1,35 @@
<template>
<div class="card p-6"><div class="flex flex-wrap items-end gap-4">
<div class="min-w-[200px] relative"><label class="input-label">{{ t('admin.usage.userFilter') }}</label>
<input v-model="userKW" type="text" class="input pr-8" :placeholder="t('admin.usage.searchUserPlaceholder')" @input="debounceSearch" @focus="showDD = true" />
<button v-if="modelValue.user_id" @click="clearUser" class="absolute right-2 top-9 text-gray-400"></button>
<div v-if="showDD && (results.length > 0 || userKW)" class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:bg-gray-800">
<button v-for="u in results" :key="u.id" @click="selectUser(u)" class="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700"><span>{{ u.email }}</span><span class="ml-2 text-xs text-gray-400">#{{ u.id }}</span></button>
</div>
</div>
<div class="min-w-[180px]"><label class="input-label">{{ t('usage.model') }}</label><Select v-model="filters.model" :options="mOpts" searchable @change="emitChange" /></div>
<div class="min-w-[150px]"><label class="input-label">{{ t('admin.usage.group') }}</label><Select v-model="filters.group_id" :options="gOpts" @change="emitChange" /></div>
<div><label class="input-label">{{ t('usage.timeRange') }}</label><DateRangePicker :start-date="startDate" :end-date="endDate" @update:startDate="$emit('update:startDate', $event)" @update:endDate="$emit('update:endDate', $event)" @change="emitChange" /></div>
<div class="ml-auto flex gap-3"><button @click="$emit('reset')" class="btn btn-secondary">{{ t('common.reset') }}</button><button @click="$emit('export')" :disabled="exporting" class="btn btn-primary">{{ t('usage.exportExcel') }}</button></div>
</div></div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, reactive } from 'vue'; import { useI18n } from 'vue-i18n'; import { adminAPI } from '@/api/admin'; import Select from '@/components/common/Select.vue'; import DateRangePicker from '@/components/common/DateRangePicker.vue'
const props = defineProps(['modelValue', 'exporting', 'startDate', 'endDate']); const emit = defineEmits(['update:modelValue', 'update:startDate', 'update:endDate', 'change', 'reset', 'export'])
const { t } = useI18n(); const filters = props.modelValue
const userKW = ref(''); const results = ref<any[]>([]); const showDD = ref(false); let timeout: any = null
const mOpts = ref([{ value: null, label: t('admin.usage.allModels') }]); const gOpts = ref([{ value: null, label: t('admin.usage.allGroups') }])
const emitChange = () => emit('change')
const debounceSearch = () => { clearTimeout(timeout); timeout = setTimeout(async () => { if(!userKW.value) { results.value = []; return }; try { results.value = await adminAPI.usage.searchUsers(userKW.value) } catch {} }, 300) }
const selectUser = (u: any) => { userKW.value = u.email; showDD.value = false; filters.user_id = u.id; emitChange() }
const clearUser = () => { userKW.value = ''; results.value = []; filters.user_id = undefined; emitChange() }
onMounted(async () => {
try { const [gs, ms] = await Promise.all([adminAPI.groups.list(1, 1000), adminAPI.dashboard.getModelStats({ start_date: props.startDate, end_date: props.endDate })])
gOpts.value.push(...gs.items.map((g: any) => ({ value: g.id, label: g.name })))
const unique = new Set<string>(); ms.models?.forEach((s: any) => s.model && unique.add(s.model))
mOpts.value.push(...Array.from(unique).sort().map(m => ({ value: m, label: m })))
} catch {}
document.addEventListener('click', (e) => { if(!(e.target as HTMLElement).closest('.relative')) showDD.value = false })
})
</script>

View File

@@ -0,0 +1,27 @@
<template>
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<div class="card p-4 flex items-center gap-3">
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30 text-blue-600"><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg></div>
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.totalRequests') }}</p><p class="text-xl font-bold">{{ stats?.total_requests?.toLocaleString() || '0' }}</p></div>
</div>
<div class="card p-4 flex items-center gap-3">
<div class="rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30 text-amber-600"><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" /></svg></div>
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.totalTokens') }}</p><p class="text-xl font-bold">{{ formatTokens(stats?.total_tokens || 0) }}</p></div>
</div>
<div class="card p-4 flex items-center gap-3">
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30 text-green-600"><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg></div>
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.totalCost') }}</p><p class="text-xl font-bold text-green-600">${{ (stats?.total_actual_cost || 0).toFixed(4) }}</p></div>
</div>
<div class="card p-4 flex items-center gap-3">
<div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30 text-purple-600"><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" /></svg></div>
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.avgDuration') }}</p><p class="text-xl font-bold">{{ formatDuration(stats?.average_duration_ms || 0) }}</p></div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'; import type { AdminUsageStatsResponse } from '@/api/admin/usage'
defineProps<{ stats: AdminUsageStatsResponse | null }>(); const { t } = useI18n()
const formatDuration = (ms: number) => ms < 1000 ? `${ms.toFixed(0)}ms` : `${(ms/1000).toFixed(2)}s`
const formatTokens = (v: number) => { if (v >= 1e9) return (v/1e9).toFixed(2) + 'B'; if (v >= 1e6) return (v/1e6).toFixed(2) + 'M'; if (v >= 1e3) return (v/1e3).toFixed(2) + 'K'; return v.toLocaleString() }
</script>

View File

@@ -0,0 +1,22 @@
<template>
<div class="card overflow-hidden"><div class="overflow-auto">
<DataTable :columns="cols" :data="data" :loading="loading">
<template #cell-user="{ row }"><div class="text-sm"><span class="font-medium text-gray-900 dark:text-white">{{ row.user?.email || '-' }}</span><span class="ml-1 text-xs text-gray-400">#{{ row.user_id }}</span></div></template>
<template #cell-model="{ value }"><span class="font-medium">{{ value }}</span></template>
<template #cell-tokens="{ row }"><div class="text-sm">In: {{ row.input_tokens.toLocaleString() }} / Out: {{ row.output_tokens.toLocaleString() }}</div></template>
<template #cell-cost="{ row }"><span class="font-medium text-green-600">${{ row.actual_cost.toFixed(6) }}</span></template>
<template #cell-created_at="{ value }"><span class="text-sm text-gray-500">{{ formatDateTime(value) }}</span></template>
<template #empty><EmptyState :message="t('usage.noRecords')" /></template>
</DataTable>
</div></div>
</template>
<script setup lang="ts">
import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import { formatDateTime } from '@/utils/format'; import DataTable from '@/components/common/DataTable.vue'; import EmptyState from '@/components/common/EmptyState.vue'
defineProps(['data', 'loading']); const { t } = useI18n()
const cols = computed(() => [
{ key: 'user', label: t('admin.usage.user') }, { key: 'model', label: t('usage.model'), sortable: true },
{ key: 'tokens', label: t('usage.tokens') }, { key: 'cost', label: t('usage.cost') },
{ key: 'created_at', label: t('usage.time'), sortable: true }
])
</script>

View File

@@ -0,0 +1,59 @@
<template>
<BaseDialog :show="show" :title="t('admin.users.setAllowedGroups')" width="normal" @close="$emit('close')">
<div v-if="user" class="space-y-4">
<div class="flex items-center gap-3 rounded-xl bg-gray-50 p-4 dark:bg-dark-700">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-primary-100">
<span class="text-lg font-medium text-primary-700">{{ user.email.charAt(0).toUpperCase() }}</span>
</div>
<p class="font-medium text-gray-900 dark:text-white">{{ user.email }}</p>
</div>
<div v-if="loading" class="flex justify-center py-8"><svg class="h-8 w-8 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>
<div v-else>
<p class="mb-3 text-sm text-gray-600">{{ t('admin.users.allowedGroupsHint') }}</p>
<div class="max-h-64 space-y-2 overflow-y-auto">
<label v-for="group in groups" :key="group.id" class="flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3 hover:bg-gray-50" :class="{'border-primary-300 bg-primary-50': selectedIds.includes(group.id)}">
<input type="checkbox" :value="group.id" v-model="selectedIds" class="h-4 w-4 rounded border-gray-300 text-primary-600" />
<div class="flex-1"><p class="font-medium text-gray-900">{{ group.name }}</p><p v-if="group.description" class="truncate text-sm text-gray-500">{{ group.description }}</p></div>
<div class="flex items-center gap-2"><span class="badge badge-gray text-xs">{{ group.platform }}</span><span v-if="group.is_exclusive" class="badge badge-purple text-xs">{{ t('admin.groups.exclusive') }}</span></div>
</label>
</div>
<div class="mt-4 border-t border-gray-200 pt-4">
<label class="flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3 hover:bg-gray-50" :class="{'border-green-300 bg-green-50': selectedIds.length === 0}">
<input type="radio" :checked="selectedIds.length === 0" @change="selectedIds = []" class="h-4 w-4 border-gray-300 text-green-600" />
<div class="flex-1"><p class="font-medium text-gray-900">{{ t('admin.users.allowAllGroups') }}</p><p class="text-sm text-gray-500">{{ t('admin.users.allowAllGroupsHint') }}</p></div>
</label>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<button @click="$emit('close')" class="btn btn-secondary">{{ t('common.cancel') }}</button>
<button @click="handleSave" :disabled="submitting" class="btn btn-primary">{{ submitting ? t('common.saving') : t('common.save') }}</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import type { User, Group } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
const props = defineProps<{ show: boolean, user: User | null }>()
const emit = defineEmits(['close', 'success']); const { t } = useI18n(); const appStore = useAppStore()
const groups = ref<Group[]>([]); const selectedIds = ref<number[]>([]); const loading = ref(false); const submitting = ref(false)
watch(() => props.show, (v) => { if(v && props.user) { selectedIds.value = props.user.allowed_groups || []; load() } })
const load = async () => { loading.value = true; try { const res = await adminAPI.groups.list(1, 1000); groups.value = res.items.filter(g => g.subscription_type === 'standard' && g.status === 'active') } catch {} finally { loading.value = false } }
const handleSave = async () => {
if (!props.user) return; submitting.value = true
try {
await adminAPI.users.update(props.user.id, { allowed_groups: selectedIds.value.length > 0 ? selectedIds.value : null })
appStore.showSuccess(t('admin.users.allowedGroupsUpdated')); emit('success'); emit('close')
} catch {} finally { submitting.value = false }
}
</script>

View File

@@ -0,0 +1,47 @@
<template>
<BaseDialog :show="show" :title="t('admin.users.userApiKeys')" width="wide" @close="$emit('close')">
<div v-if="user" class="space-y-4">
<div class="flex items-center gap-3 rounded-xl bg-gray-50 p-4 dark:bg-dark-700">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30">
<span class="text-lg font-medium text-primary-700 dark:text-primary-300">{{ user.email.charAt(0).toUpperCase() }}</span>
</div>
<div><p class="font-medium text-gray-900 dark:text-white">{{ user.email }}</p><p class="text-sm text-gray-500 dark:text-dark-400">{{ user.username }}</p></div>
</div>
<div v-if="loading" class="flex justify-center py-8"><svg class="h-8 w-8 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>
<div v-else-if="apiKeys.length === 0" class="py-8 text-center"><p class="text-sm text-gray-500">{{ t('admin.users.noApiKeys') }}</p></div>
<div v-else class="max-h-96 space-y-3 overflow-y-auto">
<div v-for="key in apiKeys" :key="key.id" class="rounded-xl border border-gray-200 bg-white p-4 dark:border-dark-600 dark:bg-dark-800">
<div class="flex items-start justify-between">
<div class="min-w-0 flex-1">
<div class="mb-1 flex items-center gap-2"><span class="font-medium text-gray-900 dark:text-white">{{ key.name }}</span><span :class="['badge text-xs', key.status === 'active' ? 'badge-success' : 'badge-danger']">{{ key.status }}</span></div>
<p class="truncate font-mono text-sm text-gray-500">{{ key.key.substring(0, 20) }}...{{ key.key.substring(key.key.length - 8) }}</p>
</div>
</div>
<div class="mt-3 flex flex-wrap gap-4 text-xs text-gray-500">
<div class="flex items-center gap-1"><span>{{ t('admin.users.group') }}: {{ key.group?.name || t('admin.users.none') }}</span></div>
<div class="flex items-center gap-1"><span>{{ t('admin.users.columns.created') }}: {{ formatDateTime(key.created_at) }}</span></div>
</div>
</div>
</div>
</div>
</BaseDialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin'
import { formatDateTime } from '@/utils/format'
import type { User, ApiKey } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
const props = defineProps<{ show: boolean, user: User | null }>()
defineEmits(['close']); const { t } = useI18n()
const apiKeys = ref<ApiKey[]>([]); const loading = ref(false)
watch(() => props.show, (v) => { if (v && props.user) load() })
const load = async () => {
if (!props.user) return; loading.value = true
try { const res = await adminAPI.users.getUserApiKeys(props.user.id); apiKeys.value = res.items || [] } catch {} finally { loading.value = false }
}
</script>

View File

@@ -0,0 +1,46 @@
<template>
<BaseDialog :show="show" :title="operation === 'add' ? t('admin.users.deposit') : t('admin.users.withdraw')" width="narrow" @close="$emit('close')">
<form v-if="user" id="balance-form" @submit.prevent="handleBalanceSubmit" class="space-y-5">
<div class="flex items-center gap-3 rounded-xl bg-gray-50 p-4 dark:bg-dark-700">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-primary-100"><span class="text-lg font-medium text-primary-700">{{ user.email.charAt(0).toUpperCase() }}</span></div>
<div class="flex-1"><p class="font-medium text-gray-900">{{ user.email }}</p><p class="text-sm text-gray-500">{{ t('admin.users.currentBalance') }}: ${{ user.balance.toFixed(2) }}</p></div>
</div>
<div>
<label class="input-label">{{ operation === 'add' ? t('admin.users.depositAmount') : t('admin.users.withdrawAmount') }}</label>
<div class="relative"><div class="absolute left-3 top-1/2 -translate-y-1/2 font-medium text-gray-500">$</div><input v-model.number="form.amount" type="number" step="0.01" min="0.01" required class="input pl-8" /></div>
</div>
<div><label class="input-label">{{ t('admin.users.notes') }}</label><textarea v-model="form.notes" rows="3" class="input"></textarea></div>
<div v-if="form.amount > 0" class="rounded-xl border border-blue-200 bg-blue-50 p-4"><div class="flex items-center justify-between text-sm"><span>{{ t('admin.users.newBalance') }}:</span><span class="font-bold">${{ calculateNewBalance().toFixed(2) }}</span></div></div>
</form>
<template #footer>
<div class="flex justify-end gap-3">
<button @click="$emit('close')" class="btn btn-secondary">{{ t('common.cancel') }}</button>
<button type="submit" form="balance-form" :disabled="submitting || !form.amount" class="btn" :class="operation === 'add' ? 'bg-emerald-600 text-white' : 'btn-danger'">{{ submitting ? t('common.saving') : t('common.confirm') }}</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import type { User } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
const props = defineProps<{ show: boolean, user: User | null, operation: 'add' | 'subtract' }>()
const emit = defineEmits(['close', 'success']); const { t } = useI18n(); const appStore = useAppStore()
const submitting = ref(false); const form = reactive({ amount: 0, notes: '' })
watch(() => props.show, (v) => { if(v) { form.amount = 0; form.notes = '' } })
const calculateNewBalance = () => (props.user ? (props.operation === 'add' ? props.user.balance + form.amount : props.user.balance - form.amount) : 0)
const handleBalanceSubmit = async () => {
if (!props.user) return; submitting.value = true
try {
await adminAPI.users.updateBalance(props.user.id, form.amount, props.operation, form.notes)
appStore.showSuccess(t('common.success')); emit('success'); emit('close')
} catch {} finally { submitting.value = false }
}
</script>

View File

@@ -0,0 +1,118 @@
<template>
<BaseDialog
:show="show"
:title="t('admin.users.createUser')"
width="normal"
@close="$emit('close')"
>
<form id="create-user-form" @submit.prevent="handleCreateUser" class="space-y-5">
<div>
<label class="input-label">{{ t('admin.users.email') }}</label>
<input
v-model="form.email"
type="email"
required
class="input"
:placeholder="t('admin.users.enterEmail')"
/>
</div>
<div>
<label class="input-label">{{ t('admin.users.password') }}</label>
<div class="flex gap-2">
<div class="relative flex-1">
<input
v-model="form.password"
type="text"
required
class="input pr-10"
:placeholder="t('admin.users.enterPassword')"
/>
<button
v-if="form.password"
type="button"
@click="copyPassword"
class="absolute right-2 top-1/2 -translate-y-1/2 rounded-lg p-1 transition-colors hover:bg-gray-100 dark:hover:bg-dark-700"
:class="passwordCopied ? 'text-green-500' : 'text-gray-400'"
>
<svg v-if="passwordCopied" class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
<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="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
</svg>
</button>
</div>
<button type="button" @click="generateRandomPassword" class="btn btn-secondary px-3">
<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="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>
<div>
<label class="input-label">{{ t('admin.users.username') }}</label>
<input v-model="form.username" type="text" class="input" :placeholder="t('admin.users.enterUsername')" />
</div>
<div>
<label class="input-label">{{ t('admin.users.notes') }}</label>
<textarea v-model="form.notes" rows="3" class="input" :placeholder="t('admin.users.enterNotes')"></textarea>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="input-label">{{ t('admin.users.columns.balance') }}</label>
<input v-model.number="form.balance" type="number" step="any" class="input" />
</div>
<div>
<label class="input-label">{{ t('admin.users.columns.concurrency') }}</label>
<input v-model.number="form.concurrency" type="number" class="input" />
</div>
</div>
</form>
<template #footer>
<div class="flex justify-end gap-3">
<button @click="$emit('close')" type="button" class="btn btn-secondary">{{ t('common.cancel') }}</button>
<button type="submit" form="create-user-form" :disabled="submitting" class="btn btn-primary">
{{ submitting ? t('admin.users.creating') : t('common.create') }}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useClipboard } from '@/composables/useClipboard'
import { adminAPI } from '@/api/admin'
import BaseDialog from '@/components/common/BaseDialog.vue'
const props = defineProps<{ show: boolean }>()
const emit = defineEmits(['close', 'success'])
const { t } = useI18n(); const appStore = useAppStore(); const { copyToClipboard } = useClipboard()
const submitting = ref(false); const passwordCopied = ref(false)
const form = reactive({ email: '', password: '', username: '', notes: '', balance: 0, concurrency: 1 })
watch(() => props.show, (v) => { if(v) { Object.assign(form, { email: '', password: '', username: '', notes: '', balance: 0, concurrency: 1 }); passwordCopied.value = false } })
const generateRandomPassword = () => {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*'
let p = ''; for (let i = 0; i < 16; i++) p += chars.charAt(Math.floor(Math.random() * chars.length))
form.password = p
}
const copyPassword = async () => {
if (form.password && await copyToClipboard(form.password, t('admin.users.passwordCopied'))) {
passwordCopied.value = true; setTimeout(() => passwordCopied.value = false, 2000)
}
}
const handleCreateUser = async () => {
submitting.value = true
try {
await adminAPI.users.create(form); appStore.showSuccess(t('admin.users.userCreated'))
emit('success'); emit('close')
} catch (e: any) {
appStore.showError(e.response?.data?.message || e.response?.data?.detail || t('admin.users.failedToCreate'))
} finally { submitting.value = false }
}
</script>

View File

@@ -0,0 +1,101 @@
<template>
<BaseDialog
:show="show"
:title="t('admin.users.editUser')"
width="normal"
@close="$emit('close')"
>
<form v-if="user" id="edit-user-form" @submit.prevent="handleUpdateUser" class="space-y-5">
<div>
<label class="input-label">{{ t('admin.users.email') }}</label>
<input v-model="form.email" type="email" class="input" />
</div>
<div>
<label class="input-label">{{ t('admin.users.password') }}</label>
<div class="flex gap-2">
<div class="relative flex-1">
<input v-model="form.password" type="text" class="input pr-10" :placeholder="t('admin.users.enterNewPassword')" />
<button v-if="form.password" type="button" @click="copyPassword" class="absolute right-2 top-1/2 -translate-y-1/2 rounded-lg p-1 transition-colors hover:bg-gray-100 dark:hover:bg-dark-700" :class="passwordCopied ? 'text-green-500' : 'text-gray-400'">
<svg v-if="passwordCopied" class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /></svg>
<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="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" /></svg>
</button>
</div>
<button type="button" @click="generatePassword" class="btn btn-secondary px-3">
<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="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>
<div>
<label class="input-label">{{ t('admin.users.username') }}</label>
<input v-model="form.username" type="text" class="input" />
</div>
<div>
<label class="input-label">{{ t('admin.users.notes') }}</label>
<textarea v-model="form.notes" rows="3" class="input"></textarea>
</div>
<div>
<label class="input-label">{{ t('admin.users.columns.concurrency') }}</label>
<input v-model.number="form.concurrency" type="number" class="input" />
</div>
<UserAttributeForm v-model="form.customAttributes" :user-id="user?.id" />
</form>
<template #footer>
<div class="flex justify-end gap-3">
<button @click="$emit('close')" type="button" class="btn btn-secondary">{{ t('common.cancel') }}</button>
<button type="submit" form="edit-user-form" :disabled="submitting" class="btn btn-primary">
{{ submitting ? t('admin.users.updating') : t('common.update') }}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useClipboard } from '@/composables/useClipboard'
import { adminAPI } from '@/api/admin'
import type { User, UserAttributeValuesMap } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import UserAttributeForm from '@/components/user/UserAttributeForm.vue'
const props = defineProps<{ show: boolean, user: User | null }>()
const emit = defineEmits(['close', 'success'])
const { t } = useI18n(); const appStore = useAppStore(); const { copyToClipboard } = useClipboard()
const submitting = ref(false); const passwordCopied = ref(false)
const form = reactive({ email: '', password: '', username: '', notes: '', concurrency: 1, customAttributes: {} as UserAttributeValuesMap })
watch(() => props.user, (u) => {
if (u) {
Object.assign(form, { email: u.email, password: '', username: u.username || '', notes: u.notes || '', concurrency: u.concurrency, customAttributes: {} })
passwordCopied.value = false
}
}, { immediate: true })
const generatePassword = () => {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*'
let p = ''; for (let i = 0; i < 16; i++) p += chars.charAt(Math.floor(Math.random() * chars.length))
form.password = p
}
const copyPassword = async () => {
if (form.password && await copyToClipboard(form.password, t('admin.users.passwordCopied'))) {
passwordCopied.value = true; setTimeout(() => passwordCopied.value = false, 2000)
}
}
const handleUpdateUser = async () => {
if (!props.user) return
submitting.value = true
try {
const data: any = { email: form.email, username: form.username, notes: form.notes, concurrency: form.concurrency }
if (form.password.trim()) data.password = form.password.trim()
await adminAPI.users.update(props.user.id, data)
if (Object.keys(form.customAttributes).length > 0) await adminAPI.userAttributes.updateUserAttributeValues(props.user.id, form.customAttributes)
appStore.showSuccess(t('admin.users.userUpdated'))
emit('success'); emit('close')
} catch (e: any) {
appStore.showError(e.response?.data?.detail || t('admin.users.failedToUpdate'))
} finally { submitting.value = false }
}
</script>

View File

@@ -0,0 +1,103 @@
<template>
<div class="w-full">
<label v-if="label" :for="id" class="input-label mb-1.5 block">
{{ label }}
<span v-if="required" class="text-red-500">*</span>
</label>
<div class="relative">
<!-- Prefix Icon Slot -->
<div
v-if="$slots.prefix"
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5 text-gray-400 dark:text-dark-400"
>
<slot name="prefix"></slot>
</div>
<input
:id="id"
ref="inputRef"
:type="type"
:value="modelValue"
:disabled="disabled"
:required="required"
:placeholder="placeholderText"
:autocomplete="autocomplete"
:readonly="readonly"
:class="[
'input w-full transition-all duration-200',
$slots.prefix ? 'pl-11' : '',
$slots.suffix ? 'pr-11' : '',
error ? 'input-error ring-2 ring-red-500/20' : '',
disabled ? 'cursor-not-allowed bg-gray-100 opacity-60 dark:bg-dark-900' : ''
]"
@input="onInput"
@change="$emit('change', ($event.target as HTMLInputElement).value)"
@blur="$emit('blur', $event)"
@focus="$emit('focus', $event)"
@keyup.enter="$emit('enter', $event)"
/>
<!-- Suffix Slot (e.g. Password Toggle or Clear Button) -->
<div
v-if="$slots.suffix"
class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 dark:text-dark-400"
>
<slot name="suffix"></slot>
</div>
</div>
<!-- Hint / Error Text -->
<p v-if="error" class="input-error-text mt-1.5">
{{ error }}
</p>
<p v-else-if="hint" class="input-hint mt-1.5">
{{ hint }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
interface Props {
modelValue: string | number | null | undefined
type?: string
label?: string
placeholder?: string
disabled?: boolean
required?: boolean
readonly?: boolean
error?: string
hint?: string
id?: string
autocomplete?: string
}
const props = withDefaults(defineProps<Props>(), {
type: 'text',
disabled: false,
required: false,
readonly: false
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'change', value: string): void
(e: 'blur', event: FocusEvent): void
(e: 'focus', event: FocusEvent): void
(e: 'enter', event: KeyboardEvent): void
}>()
const inputRef = ref<HTMLInputElement | null>(null)
const placeholderText = computed(() => props.placeholder || '')
const onInput = (event: Event) => {
const value = (event.target as HTMLInputElement).value
emit('update:modelValue', value)
}
// Expose focus method
defineExpose({
focus: () => inputRef.value?.focus(),
select: () => inputRef.value?.select()
})
</script>

View File

@@ -0,0 +1,46 @@
<template>
<div
:class="[
'animate-pulse bg-gray-200 dark:bg-dark-700',
variant === 'circle' ? 'rounded-full' : 'rounded-lg',
customClass
]"
:style="style"
></div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
variant?: 'rect' | 'circle' | 'text'
width?: string | number
height?: string | number
class?: string
}
const props = withDefaults(defineProps<Props>(), {
variant: 'rect',
width: '100%'
})
const customClass = computed(() => props.class || '')
const style = computed(() => {
const s: Record<string, string> = {}
if (props.width) {
s.width = typeof props.width === 'number' ? `${props.width}px` : props.width
}
if (props.height) {
s.height = typeof props.height === 'number' ? `${props.height}px` : props.height
} else if (props.variant === 'text') {
s.height = '1em'
s.marginTop = '0.25em'
s.marginBottom = '0.25em'
}
return s
})
</script>

View File

@@ -0,0 +1,81 @@
<template>
<div class="w-full">
<label v-if="label" :for="id" class="input-label mb-1.5 block">
{{ label }}
<span v-if="required" class="text-red-500">*</span>
</label>
<div class="relative">
<textarea
:id="id"
ref="textAreaRef"
:value="modelValue"
:disabled="disabled"
:required="required"
:placeholder="placeholderText"
:readonly="readonly"
:rows="rows"
:class="[
'input w-full min-h-[80px] transition-all duration-200 resize-y',
error ? 'input-error ring-2 ring-red-500/20' : '',
disabled ? 'cursor-not-allowed bg-gray-100 opacity-60 dark:bg-dark-900' : ''
]"
@input="onInput"
@change="$emit('change', ($event.target as HTMLTextAreaElement).value)"
@blur="$emit('blur', $event)"
@focus="$emit('focus', $event)"
></textarea>
</div>
<!-- Hint / Error Text -->
<p v-if="error" class="input-error-text mt-1.5">
{{ error }}
</p>
<p v-else-if="hint" class="input-hint mt-1.5">
{{ hint }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
interface Props {
modelValue: string | null | undefined
label?: string
placeholder?: string
disabled?: boolean
required?: boolean
readonly?: boolean
error?: string
hint?: string
id?: string
rows?: number | string
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
required: false,
readonly: false,
rows: 3
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'change', value: string): void
(e: 'blur', event: FocusEvent): void
(e: 'focus', event: FocusEvent): void
}>()
const textAreaRef = ref<HTMLTextAreaElement | null>(null)
const placeholderText = computed(() => props.placeholder || '')
const onInput = (event: Event) => {
const value = (event.target as HTMLTextAreaElement).value
emit('update:modelValue', value)
}
// Expose focus method
defineExpose({
focus: () => textAreaRef.value?.focus(),
select: () => textAreaRef.value?.select()
})
</script>

View File

@@ -0,0 +1,31 @@
<template>
<div class="space-y-6">
<div class="card p-4 flex flex-wrap items-center gap-4">
<DateRangePicker :start-date="startDate" :end-date="endDate" @update:startDate="$emit('update:startDate', $event)" @update:endDate="$emit('update:endDate', $event)" @change="$emit('dateRangeChange', $event)" />
<div class="ml-auto w-28"><Select :model-value="granularity" :options="[{value:'day', label:t('dashboard.day')}, {value:'hour', label:t('dashboard.hour')}]" @update:model-value="$emit('update:granularity', $event)" @change="$emit('granularityChange')" /></div>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div class="card p-4 min-h-[300px] relative"><div v-if="loading" class="absolute inset-0 flex items-center justify-center bg-white/50"><LoadingSpinner /></div>
<h3 class="mb-4 font-semibold">{{ t('dashboard.modelDistribution') }}</h3>
<div class="h-48"><Doughnut v-if="modelData" :data="modelData" :options="{maintainAspectRatio:false}" /></div>
</div>
<div class="card p-4 min-h-[300px] relative"><div v-if="loading" class="absolute inset-0 flex items-center justify-center bg-white/50"><LoadingSpinner /></div>
<h3 class="mb-4 font-semibold">{{ t('dashboard.tokenUsageTrend') }}</h3>
<div class="h-48"><Line v-if="trendData" :data="trendData" :options="{maintainAspectRatio:false}" /></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import LoadingSpinner from '@/components/common/LoadingSpinner.vue'; import DateRangePicker from '@/components/common/DateRangePicker.vue'; import Select from '@/components/common/Select.vue'; import { Line, Doughnut } from 'vue-chartjs'
import type { TrendDataPoint, ModelStat } from '@/types'
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, ArcElement, Title, Tooltip, Legend, Filler } from 'chart.js'
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, ArcElement, Title, Tooltip, Legend, Filler)
const props = defineProps<{ loading: boolean, startDate: string, endDate: string, granularity: string, trend: TrendDataPoint[], models: ModelStat[] }>()
defineEmits(['update:startDate', 'update:endDate', 'update:granularity', 'dateRangeChange', 'granularityChange'])
const { t } = useI18n()
const modelData = computed(() => !props.models?.length ? null : { labels: props.models.map((m:ModelStat) => m.model), datasets: [{ data: props.models.map((m:ModelStat) => m.total_tokens), backgroundColor: ['#3b82f6','#10b981','#f59e0b','#ef4444','#8b5cf6'] }] })
const trendData = computed(() => !props.trend?.length ? null : { labels: props.trend.map((d:TrendDataPoint) => d.date), datasets: [{ label: 'Input', data: props.trend.map((d:TrendDataPoint) => d.input_tokens), borderColor: '#3b82f6', tension: 0.3 }] })
</script>

View File

@@ -0,0 +1,15 @@
<template>
<div class="card p-6">
<h2 class="font-semibold mb-4">{{ t('dashboard.quickActions') }}</h2>
<div class="space-y-2">
<button @click="router.push('/keys')" class="w-full text-left p-3 hover:bg-gray-100 rounded-lg flex items-center gap-3"><div class="p-2 bg-blue-100 rounded-lg text-blue-600">🔑</div><span>{{ t('dashboard.createApiKey') }}</span></button>
<button @click="router.push('/usage')" class="w-full text-left p-3 hover:bg-gray-100 rounded-lg flex items-center gap-3"><div class="p-2 bg-green-100 rounded-lg text-green-600">📊</div><span>{{ t('dashboard.viewUsage') }}</span></button>
<button @click="router.push('/redeem')" class="w-full text-left p-3 hover:bg-gray-100 rounded-lg flex items-center gap-3"><div class="p-2 bg-amber-100 rounded-lg text-amber-600">🎁</div><span>{{ t('dashboard.redeemCode') }}</span></button>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'; import { useI18n } from 'vue-i18n'
const router = useRouter(); const { t } = useI18n()
</script>

View File

@@ -0,0 +1,18 @@
<template>
<div class="card p-6">
<div class="flex items-center justify-between mb-4"><h2 class="font-semibold">{{ t('dashboard.recentUsage') }}</h2></div>
<div v-if="loading" class="flex justify-center py-8"><LoadingSpinner /></div>
<div v-else-if="!data.length" class="text-center py-8 text-gray-500">{{ t('dashboard.noUsageRecords') }}</div>
<div v-else class="space-y-3">
<div v-for="l in data" :key="l.id" class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div><p class="text-sm font-medium">{{ l.model }}</p><p class="text-xs text-gray-400">{{ formatDateTime(l.created_at) }}</p></div>
<div class="text-right"><p class="text-sm font-bold text-green-600">${{ l.actual_cost.toFixed(4) }}</p></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'; import LoadingSpinner from '@/components/common/LoadingSpinner.vue'; import { formatDateTime } from '@/utils/format'
defineProps(['data', 'loading']); const { t } = useI18n()
</script>

View File

@@ -0,0 +1,24 @@
<template>
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<div v-if="!isSimple" class="card p-4 flex items-center gap-3">
<div class="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30 text-emerald-600"><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75" /></svg></div>
<div><p class="text-xs text-gray-500">{{ t('dashboard.balance') }}</p><p class="text-xl font-bold text-emerald-600">${{ balance.toFixed(2) }}</p></div>
</div>
<div class="card p-4 flex items-center gap-3">
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30 text-blue-600"><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M15.75 5.25a3 3 0 013 3" /></svg></div>
<div><p class="text-xs text-gray-500">{{ t('dashboard.apiKeys') }}</p><p class="text-xl font-bold">{{ stats?.total_api_keys || 0 }}</p></div>
</div>
<div class="card p-4 flex items-center gap-3">
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30 text-green-600"><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M3 13.125h2.25" /></svg></div>
<div><p class="text-xs text-gray-500">{{ t('dashboard.todayRequests') }}</p><p class="text-xl font-bold">{{ stats?.today_requests || 0 }}</p></div>
</div>
<div class="card p-4 flex items-center gap-3">
<div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30 text-purple-600"><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M12 6v12" /></svg></div>
<div><p class="text-xs text-gray-500">{{ t('dashboard.todayCost') }}</p><p class="text-xl font-bold text-purple-600">${{ (stats?.today_actual_cost || 0).toFixed(4) }}</p></div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'; defineProps(['stats', 'balance', 'isSimple']); const { t } = useI18n()
</script>

View File

@@ -0,0 +1,74 @@
<template>
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
{{ t('profile.editProfile') }}
</h2>
</div>
<div class="px-6 py-6">
<form @submit.prevent="handleUpdateProfile" class="space-y-4">
<div>
<label for="username" class="input-label">
{{ t('profile.username') }}
</label>
<input
id="username"
v-model="username"
type="text"
class="input"
:placeholder="t('profile.enterUsername')"
/>
</div>
<div class="flex justify-end pt-4">
<button type="submit" :disabled="loading" class="btn btn-primary">
{{ loading ? t('profile.updating') : t('profile.updateProfile') }}
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { useAppStore } from '@/stores/app'
import { userAPI } from '@/api'
const props = defineProps<{
initialUsername: string
}>()
const { t } = useI18n()
const authStore = useAuthStore()
const appStore = useAppStore()
const username = ref(props.initialUsername)
const loading = ref(false)
watch(() => props.initialUsername, (val) => {
username.value = val
})
const handleUpdateProfile = async () => {
if (!username.value.trim()) {
appStore.showError(t('profile.usernameRequired'))
return
}
loading.value = true
try {
const updatedUser = await userAPI.updateProfile({
username: username.value
})
authStore.user = updatedUser
appStore.showSuccess(t('profile.updateSuccess'))
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('profile.updateFailed'))
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,81 @@
<template>
<div class="card overflow-hidden">
<div
class="border-b border-gray-100 bg-gradient-to-r from-primary-500/10 to-primary-600/5 px-6 py-5 dark:border-dark-700 dark:from-primary-500/20 dark:to-primary-600/10"
>
<div class="flex items-center gap-4">
<!-- Avatar -->
<div
class="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-500 to-primary-600 text-2xl font-bold text-white shadow-lg shadow-primary-500/20"
>
{{ user?.email?.charAt(0).toUpperCase() || 'U' }}
</div>
<div class="min-w-0 flex-1">
<h2 class="truncate text-lg font-semibold text-gray-900 dark:text-white">
{{ user?.email }}
</h2>
<div class="mt-1 flex items-center gap-2">
<span :class="['badge', user?.role === 'admin' ? 'badge-primary' : 'badge-gray']">
{{ user?.role === 'admin' ? t('profile.administrator') : t('profile.user') }}
</span>
<span
:class="['badge', user?.status === 'active' ? 'badge-success' : 'badge-danger']"
>
{{ user?.status }}
</span>
</div>
</div>
</div>
</div>
<div class="px-6 py-4">
<div class="space-y-3">
<div class="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
<svg
class="h-4 w-4 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
/>
</svg>
<span class="truncate">{{ user?.email }}</span>
</div>
<div
v-if="user?.username"
class="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400"
>
<svg
class="h-4 w-4 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
/>
</svg>
<span class="truncate">{{ user.username }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { User } from '@/types'
defineProps<{
user: User | null
}>()
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,109 @@
<template>
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
{{ t('profile.changePassword') }}
</h2>
</div>
<div class="px-6 py-6">
<form @submit.prevent="handleChangePassword" class="space-y-4">
<div>
<label for="old_password" class="input-label">
{{ t('profile.currentPassword') }}
</label>
<input
id="old_password"
v-model="form.old_password"
type="password"
required
autocomplete="current-password"
class="input"
/>
</div>
<div>
<label for="new_password" class="input-label">
{{ t('profile.newPassword') }}
</label>
<input
id="new_password"
v-model="form.new_password"
type="password"
required
autocomplete="new-password"
class="input"
/>
<p class="input-hint">
{{ t('profile.passwordHint') }}
</p>
</div>
<div>
<label for="confirm_password" class="input-label">
{{ t('profile.confirmNewPassword') }}
</label>
<input
id="confirm_password"
v-model="form.confirm_password"
type="password"
required
autocomplete="new-password"
class="input"
/>
<p
v-if="form.new_password && form.confirm_password && form.new_password !== form.confirm_password"
class="input-error-text"
>
{{ t('profile.passwordsNotMatch') }}
</p>
</div>
<div class="flex justify-end pt-4">
<button type="submit" :disabled="loading" class="btn btn-primary">
{{ loading ? t('profile.changingPassword') : t('profile.changePasswordButton') }}
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { userAPI } from '@/api'
const { t } = useI18n()
const appStore = useAppStore()
const loading = ref(false)
const form = ref({
old_password: '',
new_password: '',
confirm_password: ''
})
const handleChangePassword = async () => {
if (form.value.new_password !== form.value.confirm_password) {
appStore.showError(t('profile.passwordsNotMatch'))
return
}
if (form.value.new_password.length < 8) {
appStore.showError(t('profile.passwordTooShort'))
return
}
loading.value = true
try {
await userAPI.changePassword(form.value.old_password, form.value.new_password)
form.value = { old_password: '', new_password: '', confirm_password: '' }
appStore.showSuccess(t('profile.passwordChangeSuccess'))
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('profile.passwordChangeFailed'))
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,102 @@
import { ref, reactive, onUnmounted } from 'vue'
import { useDebounceFn } from '@vueuse/core'
interface PaginationState {
page: number
page_size: number
total: number
pages: number
}
interface TableLoaderOptions<T, P> {
fetchFn: (page: number, pageSize: number, params: P, options?: { signal: AbortSignal }) => Promise<{
items: T[]
total: number
pages: number
}>
initialParams?: P
pageSize?: number
debounceMs?: number
}
export function useTableLoader<T, P extends Record<string, any>>(options: TableLoaderOptions<T, P>) {
const { fetchFn, initialParams, pageSize = 20, debounceMs = 300 } = options
const items = ref<T[]>([])
const loading = ref(false)
const params = reactive<P>({ ...(initialParams || {}) } as P)
const pagination = reactive<PaginationState>({
page: 1,
page_size: pageSize,
total: 0,
pages: 0
})
let abortController: AbortController | null = null
const isAbortError = (error: any) => {
return error?.name === 'AbortError' || error?.code === 'ERR_CANCELED'
}
const load = async () => {
if (abortController) {
abortController.abort()
}
abortController = new AbortController()
loading.value = true
try {
const response = await fetchFn(
pagination.page,
pagination.page_size,
params,
{ signal: abortController.signal }
)
items.value = response.items
pagination.total = response.total
pagination.pages = response.pages
} catch (error) {
if (!isAbortError(error)) {
throw error
}
} finally {
if (abortController?.signal.aborted === false) {
loading.value = false
}
}
}
const reload = () => {
pagination.page = 1
return load()
}
const debouncedLoad = useDebounceFn(reload, debounceMs)
const handlePageChange = (page: number) => {
pagination.page = page
load()
}
const handlePageSizeChange = (size: number) => {
pagination.page_size = size
reload()
}
onUnmounted(() => {
abortController?.abort()
})
return {
items,
loading,
params,
pagination,
load,
reload,
debouncedLoad,
handlePageChange,
handlePageSizeChange
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff