Commit 3c341947 authored by yangjianbo's avatar yangjianbo
Browse files

Merge branch 'main' into test-dev

parents 3a7d3387 c01db6b1
...@@ -307,6 +307,35 @@ ...@@ -307,6 +307,35 @@
@apply flex items-center justify-end gap-3; @apply flex items-center justify-end gap-3;
} }
/* ============ Dialog ============ */
.dialog-overlay {
@apply fixed inset-0 z-50;
@apply bg-black/40 dark:bg-black/60;
@apply flex items-center justify-center p-4;
}
.dialog-container {
@apply flex w-full flex-col;
@apply max-h-[90vh];
@apply rounded-2xl bg-white dark:bg-dark-800;
@apply shadow-xl;
}
.dialog-header {
@apply border-b border-gray-100 px-6 py-4 dark:border-dark-700;
@apply flex items-center justify-between;
}
.dialog-body {
@apply overflow-y-auto px-6 py-4;
}
.dialog-footer {
@apply border-t border-gray-100 px-6 py-4 dark:border-dark-700;
@apply bg-gray-50/60 dark:bg-dark-900/40;
@apply flex items-center justify-end gap-3;
}
/* ============ Toast 通知 ============ */ /* ============ Toast 通知 ============ */
.toast { .toast {
@apply fixed right-4 top-4 z-[100]; @apply fixed right-4 top-4 z-[100];
......
...@@ -60,7 +60,11 @@ export interface PublicSettings { ...@@ -60,7 +60,11 @@ export interface PublicSettings {
export interface AuthResponse { export interface AuthResponse {
access_token: string access_token: string
token_type: string token_type: string
user: User user: User & { run_mode?: 'standard' | 'simple' }
}
export interface CurrentUserResponse extends User {
run_mode?: 'standard' | 'simple'
} }
// ==================== Subscription Types ==================== // ==================== Subscription Types ====================
......
This diff is collapsed.
...@@ -407,10 +407,20 @@ const trendData = ref<TrendDataPoint[]>([]) ...@@ -407,10 +407,20 @@ const trendData = ref<TrendDataPoint[]>([])
const modelStats = ref<ModelStat[]>([]) const modelStats = ref<ModelStat[]>([])
const userTrend = ref<UserUsageTrendPoint[]>([]) const userTrend = ref<UserUsageTrendPoint[]>([])
// Helper function to format date in local timezone
const formatLocalDate = (date: Date): string => {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
// Initialize date range immediately
const now = new Date()
const weekAgo = new Date(now)
weekAgo.setDate(weekAgo.getDate() - 6)
// Date range // Date range
const granularity = ref<'day' | 'hour'>('day') const granularity = ref<'day' | 'hour'>('day')
const startDate = ref('') const startDate = ref(formatLocalDate(weekAgo))
const endDate = ref('') const endDate = ref(formatLocalDate(now))
// Granularity options for Select component // Granularity options for Select component
const granularityOptions = computed(() => [ const granularityOptions = computed(() => [
...@@ -597,18 +607,6 @@ const onDateRangeChange = (range: { ...@@ -597,18 +607,6 @@ const onDateRangeChange = (range: {
loadChartData() loadChartData()
} }
// Initialize default date range
const initializeDateRange = () => {
const now = new Date()
const today = now.toISOString().split('T')[0]
const weekAgo = new Date(now)
weekAgo.setDate(weekAgo.getDate() - 6)
startDate.value = weekAgo.toISOString().split('T')[0]
endDate.value = today
granularity.value = 'day'
}
// Load data // Load data
const loadDashboardStats = async () => { const loadDashboardStats = async () => {
loading.value = true loading.value = true
...@@ -649,7 +647,6 @@ const loadChartData = async () => { ...@@ -649,7 +647,6 @@ const loadChartData = async () => {
onMounted(() => { onMounted(() => {
loadDashboardStats() loadDashboardStats()
initializeDateRange()
loadChartData() loadChartData()
}) })
</script> </script>
......
...@@ -223,18 +223,19 @@ ...@@ -223,18 +223,19 @@
:total="pagination.total" :total="pagination.total"
:page-size="pagination.page_size" :page-size="pagination.page_size"
@update:page="handlePageChange" @update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/> />
</template> </template>
</TablePageLayout> </TablePageLayout>
<!-- Create Group Modal --> <!-- Create Group Modal -->
<Modal <BaseDialog
:show="showCreateModal" :show="showCreateModal"
:title="t('admin.groups.createGroup')" :title="t('admin.groups.createGroup')"
size="lg" width="normal"
@close="closeCreateModal" @close="closeCreateModal"
> >
<form @submit.prevent="handleCreateGroup" class="space-y-5"> <form id="create-group-form" @submit.prevent="handleCreateGroup" class="space-y-5">
<div> <div>
<label class="input-label">{{ t('admin.groups.form.name') }}</label> <label class="input-label">{{ t('admin.groups.form.name') }}</label>
<input <input
...@@ -271,34 +272,66 @@ ...@@ -271,34 +272,66 @@
/> />
<p class="input-hint">{{ t('admin.groups.rateMultiplierHint') }}</p> <p class="input-hint">{{ t('admin.groups.rateMultiplierHint') }}</p>
</div> </div>
<div v-if="createForm.subscription_type !== 'subscription'" class="flex items-center gap-3"> <div v-if="createForm.subscription_type !== 'subscription'">
<button <div class="mb-1.5 flex items-center gap-1">
type="button" <label class="text-sm font-medium text-gray-700 dark:text-gray-300">
@click="createForm.is_exclusive = !createForm.is_exclusive" {{ t('admin.groups.form.exclusive') }}
:class="[ </label>
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors', <!-- Help Tooltip -->
createForm.is_exclusive ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600' <div class="group relative inline-flex">
]" <svg
> class="h-3.5 w-3.5 cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
<span fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<!-- Tooltip Popover -->
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100">
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800">
<p class="mb-2 text-xs font-medium">{{ t('admin.groups.exclusiveTooltip.title') }}</p>
<p class="mb-2 text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.exclusiveTooltip.description') }}
</p>
<div class="rounded bg-gray-800 p-2 dark:bg-gray-700">
<p class="text-xs leading-relaxed text-gray-300">
<span class="text-primary-400">💡 {{ t('admin.groups.exclusiveTooltip.example') }}</span>
{{ t('admin.groups.exclusiveTooltip.exampleContent') }}
</p>
</div>
<!-- Arrow -->
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
</div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<button
type="button"
@click="createForm.is_exclusive = !createForm.is_exclusive"
:class="[ :class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform', 'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
createForm.is_exclusive ? 'translate-x-6' : 'translate-x-1' createForm.is_exclusive ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
]" ]"
/> >
</button> <span
<label class="text-sm text-gray-700 dark:text-gray-300"> :class="[
{{ t('admin.groups.exclusiveHint') }} 'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
</label> createForm.is_exclusive ? 'translate-x-6' : 'translate-x-1'
]"
/>
</button>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ createForm.is_exclusive ? t('admin.groups.exclusive') : t('admin.groups.public') }}
</span>
</div>
</div> </div>
<!-- Subscription Configuration --> <!-- Subscription Configuration -->
<div class="mt-4 border-t pt-4"> <div class="mt-4 border-t pt-4">
<h4 class="mb-4 text-sm font-medium text-gray-900 dark:text-white"> <div>
{{ t('admin.groups.subscription.title') }}
</h4>
<div class="mb-4">
<label class="input-label">{{ t('admin.groups.subscription.type') }}</label> <label class="input-label">{{ t('admin.groups.subscription.type') }}</label>
<Select v-model="createForm.subscription_type" :options="subscriptionTypeOptions" /> <Select v-model="createForm.subscription_type" :options="subscriptionTypeOptions" />
<p class="input-hint">{{ t('admin.groups.subscription.typeHint') }}</p> <p class="input-hint">{{ t('admin.groups.subscription.typeHint') }}</p>
...@@ -345,11 +378,19 @@ ...@@ -345,11 +378,19 @@
</div> </div>
</div> </div>
</form>
<template #footer>
<div class="flex justify-end gap-3 pt-4"> <div class="flex justify-end gap-3 pt-4">
<button @click="closeCreateModal" type="button" class="btn btn-secondary"> <button @click="closeCreateModal" type="button" class="btn btn-secondary">
{{ t('common.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button type="submit" :disabled="submitting" class="btn btn-primary"> <button
type="submit"
form="create-group-form"
:disabled="submitting"
class="btn btn-primary"
>
<svg <svg
v-if="submitting" v-if="submitting"
class="-ml-1 mr-2 h-4 w-4 animate-spin" class="-ml-1 mr-2 h-4 w-4 animate-spin"
...@@ -373,17 +414,22 @@ ...@@ -373,17 +414,22 @@
{{ submitting ? t('admin.groups.creating') : t('common.create') }} {{ submitting ? t('admin.groups.creating') : t('common.create') }}
</button> </button>
</div> </div>
</form> </template>
</Modal> </BaseDialog>
<!-- Edit Group Modal --> <!-- Edit Group Modal -->
<Modal <BaseDialog
:show="showEditModal" :show="showEditModal"
:title="t('admin.groups.editGroup')" :title="t('admin.groups.editGroup')"
size="lg" width="normal"
@close="closeEditModal" @close="closeEditModal"
> >
<form v-if="editingGroup" @submit.prevent="handleUpdateGroup" class="space-y-5"> <form
v-if="editingGroup"
id="edit-group-form"
@submit.prevent="handleUpdateGroup"
class="space-y-5"
>
<div> <div>
<label class="input-label">{{ t('admin.groups.form.name') }}</label> <label class="input-label">{{ t('admin.groups.form.name') }}</label>
<input v-model="editForm.name" type="text" required class="input" /> <input v-model="editForm.name" type="text" required class="input" />
...@@ -408,25 +454,61 @@ ...@@ -408,25 +454,61 @@
class="input" class="input"
/> />
</div> </div>
<div v-if="editForm.subscription_type !== 'subscription'" class="flex items-center gap-3"> <div v-if="editForm.subscription_type !== 'subscription'">
<button <div class="mb-1.5 flex items-center gap-1">
type="button" <label class="text-sm font-medium text-gray-700 dark:text-gray-300">
@click="editForm.is_exclusive = !editForm.is_exclusive" {{ t('admin.groups.form.exclusive') }}
:class="[ </label>
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors', <!-- Help Tooltip -->
editForm.is_exclusive ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600' <div class="group relative inline-flex">
]" <svg
> class="h-3.5 w-3.5 cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
<span fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<!-- Tooltip Popover -->
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100">
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800">
<p class="mb-2 text-xs font-medium">{{ t('admin.groups.exclusiveTooltip.title') }}</p>
<p class="mb-2 text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.exclusiveTooltip.description') }}
</p>
<div class="rounded bg-gray-800 p-2 dark:bg-gray-700">
<p class="text-xs leading-relaxed text-gray-300">
<span class="text-primary-400">💡 {{ t('admin.groups.exclusiveTooltip.example') }}</span>
{{ t('admin.groups.exclusiveTooltip.exampleContent') }}
</p>
</div>
<!-- Arrow -->
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
</div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<button
type="button"
@click="editForm.is_exclusive = !editForm.is_exclusive"
:class="[ :class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform', 'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
editForm.is_exclusive ? 'translate-x-6' : 'translate-x-1' editForm.is_exclusive ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
]" ]"
/> >
</button> <span
<label class="text-sm text-gray-700 dark:text-gray-300"> :class="[
{{ t('admin.groups.exclusiveHint') }} 'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
</label> editForm.is_exclusive ? 'translate-x-6' : 'translate-x-1'
]"
/>
</button>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ editForm.is_exclusive ? t('admin.groups.exclusive') : t('admin.groups.public') }}
</span>
</div>
</div> </div>
<div> <div>
<label class="input-label">{{ t('admin.groups.form.status') }}</label> <label class="input-label">{{ t('admin.groups.form.status') }}</label>
...@@ -435,11 +517,7 @@ ...@@ -435,11 +517,7 @@
<!-- Subscription Configuration --> <!-- Subscription Configuration -->
<div class="mt-4 border-t pt-4"> <div class="mt-4 border-t pt-4">
<h4 class="mb-4 text-sm font-medium text-gray-900 dark:text-white"> <div>
{{ t('admin.groups.subscription.title') }}
</h4>
<div class="mb-4">
<label class="input-label">{{ t('admin.groups.subscription.type') }}</label> <label class="input-label">{{ t('admin.groups.subscription.type') }}</label>
<Select <Select
v-model="editForm.subscription_type" v-model="editForm.subscription_type"
...@@ -490,11 +568,19 @@ ...@@ -490,11 +568,19 @@
</div> </div>
</div> </div>
</form>
<template #footer>
<div class="flex justify-end gap-3 pt-4"> <div class="flex justify-end gap-3 pt-4">
<button @click="closeEditModal" type="button" class="btn btn-secondary"> <button @click="closeEditModal" type="button" class="btn btn-secondary">
{{ t('common.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button type="submit" :disabled="submitting" class="btn btn-primary"> <button
type="submit"
form="edit-group-form"
:disabled="submitting"
class="btn btn-primary"
>
<svg <svg
v-if="submitting" v-if="submitting"
class="-ml-1 mr-2 h-4 w-4 animate-spin" class="-ml-1 mr-2 h-4 w-4 animate-spin"
...@@ -518,8 +604,8 @@ ...@@ -518,8 +604,8 @@
{{ submitting ? t('admin.groups.updating') : t('common.update') }} {{ submitting ? t('admin.groups.updating') : t('common.update') }}
</button> </button>
</div> </div>
</form> </template>
</Modal> </BaseDialog>
<!-- Delete Confirmation Dialog --> <!-- Delete Confirmation Dialog -->
<ConfirmDialog <ConfirmDialog
...@@ -546,7 +632,7 @@ import AppLayout from '@/components/layout/AppLayout.vue' ...@@ -546,7 +632,7 @@ import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue' import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.vue' import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue' import Pagination from '@/components/common/Pagination.vue'
import Modal from '@/components/common/Modal.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import EmptyState from '@/components/common/EmptyState.vue' import EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue' import Select from '@/components/common/Select.vue'
...@@ -616,6 +702,8 @@ const pagination = reactive({ ...@@ -616,6 +702,8 @@ const pagination = reactive({
pages: 0 pages: 0
}) })
let abortController: AbortController | null = null
const showCreateModal = ref(false) const showCreateModal = ref(false)
const showEditModal = ref(false) const showEditModal = ref(false)
const showDeleteDialog = ref(false) const showDeleteDialog = ref(false)
...@@ -660,21 +748,33 @@ const deleteConfirmMessage = computed(() => { ...@@ -660,21 +748,33 @@ const deleteConfirmMessage = computed(() => {
}) })
const loadGroups = async () => { const loadGroups = async () => {
if (abortController) {
abortController.abort()
}
const currentController = new AbortController()
abortController = currentController
const { signal } = currentController
loading.value = true loading.value = true
try { try {
const response = await adminAPI.groups.list(pagination.page, pagination.page_size, { const response = await adminAPI.groups.list(pagination.page, pagination.page_size, {
platform: (filters.platform as GroupPlatform) || undefined, platform: (filters.platform as GroupPlatform) || undefined,
status: filters.status as any, status: filters.status as any,
is_exclusive: filters.is_exclusive ? filters.is_exclusive === 'true' : undefined is_exclusive: filters.is_exclusive ? filters.is_exclusive === 'true' : undefined
}) }, { signal })
if (signal.aborted) return
groups.value = response.items groups.value = response.items
pagination.total = response.total pagination.total = response.total
pagination.pages = response.pages pagination.pages = response.pages
} catch (error) { } catch (error: any) {
if (signal.aborted || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') {
return
}
appStore.showError(t('admin.groups.failedToLoad')) appStore.showError(t('admin.groups.failedToLoad'))
console.error('Error loading groups:', error) console.error('Error loading groups:', error)
} finally { } finally {
loading.value = false if (abortController === currentController && !signal.aborted) {
loading.value = false
}
} }
} }
...@@ -683,6 +783,12 @@ const handlePageChange = (page: number) => { ...@@ -683,6 +783,12 @@ const handlePageChange = (page: number) => {
loadGroups() loadGroups()
} }
const handlePageSizeChange = (pageSize: number) => {
pagination.page_size = pageSize
pagination.page = 1
loadGroups()
}
const closeCreateModal = () => { const closeCreateModal = () => {
showCreateModal.value = false showCreateModal.value = false
createForm.name = '' createForm.name = ''
......
...@@ -209,15 +209,16 @@ ...@@ -209,15 +209,16 @@
:total="pagination.total" :total="pagination.total"
:page-size="pagination.page_size" :page-size="pagination.page_size"
@update:page="handlePageChange" @update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/> />
</template> </template>
</TablePageLayout> </TablePageLayout>
<!-- Create Proxy Modal --> <!-- Create Proxy Modal -->
<Modal <BaseDialog
:show="showCreateModal" :show="showCreateModal"
:title="t('admin.proxies.createProxy')" :title="t('admin.proxies.createProxy')"
size="lg" width="normal"
@close="closeCreateModal" @close="closeCreateModal"
> >
<!-- Tab Switch --> <!-- Tab Switch -->
...@@ -271,7 +272,12 @@ ...@@ -271,7 +272,12 @@
</div> </div>
<!-- Standard Add Form --> <!-- Standard Add Form -->
<form v-if="createMode === 'standard'" @submit.prevent="handleCreateProxy" class="space-y-5"> <form
v-if="createMode === 'standard'"
id="create-proxy-form"
@submit.prevent="handleCreateProxy"
class="space-y-5"
>
<div> <div>
<label class="input-label">{{ t('admin.proxies.name') }}</label> <label class="input-label">{{ t('admin.proxies.name') }}</label>
<input <input
...@@ -329,34 +335,6 @@ ...@@ -329,34 +335,6 @@
/> />
</div> </div>
<div class="flex justify-end gap-3 pt-4">
<button @click="closeCreateModal" type="button" class="btn btn-secondary">
{{ t('common.cancel') }}
</button>
<button type="submit" :disabled="submitting" class="btn btn-primary">
<svg
v-if="submitting"
class="-ml-1 mr-2 h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{ submitting ? t('admin.proxies.creating') : t('common.create') }}
</button>
</div>
</form> </form>
<!-- Batch Add Form --> <!-- Batch Add Form -->
...@@ -435,11 +413,44 @@ ...@@ -435,11 +413,44 @@
</div> </div>
</div> </div>
<div class="flex justify-end gap-3 pt-4"> </div>
<template #footer>
<div class="flex justify-end gap-3">
<button @click="closeCreateModal" type="button" class="btn btn-secondary"> <button @click="closeCreateModal" type="button" class="btn btn-secondary">
{{ t('common.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button <button
v-if="createMode === 'standard'"
type="submit"
form="create-proxy-form"
:disabled="submitting"
class="btn btn-primary"
>
<svg
v-if="submitting"
class="-ml-1 mr-2 h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{ submitting ? t('admin.proxies.creating') : t('common.create') }}
</button>
<button
v-else
@click="handleBatchCreate" @click="handleBatchCreate"
type="button" type="button"
:disabled="submitting || batchParseResult.valid === 0" :disabled="submitting || batchParseResult.valid === 0"
...@@ -472,17 +483,22 @@ ...@@ -472,17 +483,22 @@
}} }}
</button> </button>
</div> </div>
</div> </template>
</Modal> </BaseDialog>
<!-- Edit Proxy Modal --> <!-- Edit Proxy Modal -->
<Modal <BaseDialog
:show="showEditModal" :show="showEditModal"
:title="t('admin.proxies.editProxy')" :title="t('admin.proxies.editProxy')"
size="lg" width="normal"
@close="closeEditModal" @close="closeEditModal"
> >
<form v-if="editingProxy" @submit.prevent="handleUpdateProxy" class="space-y-5"> <form
v-if="editingProxy"
id="edit-proxy-form"
@submit.prevent="handleUpdateProxy"
class="space-y-5"
>
<div> <div>
<label class="input-label">{{ t('admin.proxies.name') }}</label> <label class="input-label">{{ t('admin.proxies.name') }}</label>
<input v-model="editForm.name" type="text" required class="input" /> <input v-model="editForm.name" type="text" required class="input" />
...@@ -526,11 +542,20 @@ ...@@ -526,11 +542,20 @@
<Select v-model="editForm.status" :options="editStatusOptions" /> <Select v-model="editForm.status" :options="editStatusOptions" />
</div> </div>
<div class="flex justify-end gap-3 pt-4"> </form>
<template #footer>
<div class="flex justify-end gap-3">
<button @click="closeEditModal" type="button" class="btn btn-secondary"> <button @click="closeEditModal" type="button" class="btn btn-secondary">
{{ t('common.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button type="submit" :disabled="submitting" class="btn btn-primary"> <button
v-if="editingProxy"
type="submit"
form="edit-proxy-form"
:disabled="submitting"
class="btn btn-primary"
>
<svg <svg
v-if="submitting" v-if="submitting"
class="-ml-1 mr-2 h-4 w-4 animate-spin" class="-ml-1 mr-2 h-4 w-4 animate-spin"
...@@ -554,8 +579,8 @@ ...@@ -554,8 +579,8 @@
{{ submitting ? t('admin.proxies.updating') : t('common.update') }} {{ submitting ? t('admin.proxies.updating') : t('common.update') }}
</button> </button>
</div> </div>
</form> </template>
</Modal> </BaseDialog>
<!-- Delete Confirmation Dialog --> <!-- Delete Confirmation Dialog -->
<ConfirmDialog <ConfirmDialog
...@@ -582,7 +607,7 @@ import AppLayout from '@/components/layout/AppLayout.vue' ...@@ -582,7 +607,7 @@ import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue' import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.vue' import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue' import Pagination from '@/components/common/Pagination.vue'
import Modal from '@/components/common/Modal.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import EmptyState from '@/components/common/EmptyState.vue' import EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue' import Select from '@/components/common/Select.vue'
...@@ -682,22 +707,44 @@ const editForm = reactive({ ...@@ -682,22 +707,44 @@ const editForm = reactive({
status: 'active' as 'active' | 'inactive' status: 'active' as 'active' | 'inactive'
}) })
let abortController: AbortController | null = null
const isAbortError = (error: unknown) => {
if (!error || typeof error !== 'object') return false
const maybeError = error as { name?: string; code?: string }
return maybeError.name === 'AbortError' || maybeError.code === 'ERR_CANCELED'
}
const loadProxies = async () => { const loadProxies = async () => {
if (abortController) {
abortController.abort()
}
const currentAbortController = new AbortController()
abortController = currentAbortController
loading.value = true loading.value = true
try { try {
const response = await adminAPI.proxies.list(pagination.page, pagination.page_size, { const response = await adminAPI.proxies.list(pagination.page, pagination.page_size, {
protocol: filters.protocol || undefined, protocol: filters.protocol || undefined,
status: filters.status as any, status: filters.status as any,
search: searchQuery.value || undefined search: searchQuery.value || undefined
}) }, { signal: currentAbortController.signal })
if (currentAbortController.signal.aborted || abortController !== currentAbortController) {
return
}
proxies.value = response.items proxies.value = response.items
pagination.total = response.total pagination.total = response.total
pagination.pages = response.pages pagination.pages = response.pages
} catch (error) { } catch (error) {
if (isAbortError(error)) {
return
}
appStore.showError(t('admin.proxies.failedToLoad')) appStore.showError(t('admin.proxies.failedToLoad'))
console.error('Error loading proxies:', error) console.error('Error loading proxies:', error)
} finally { } finally {
loading.value = false if (abortController === currentAbortController) {
loading.value = false
abortController = null
}
} }
} }
...@@ -715,6 +762,12 @@ const handlePageChange = (page: number) => { ...@@ -715,6 +762,12 @@ const handlePageChange = (page: number) => {
loadProxies() loadProxies()
} }
const handlePageSizeChange = (pageSize: number) => {
pagination.page_size = pageSize
pagination.page = 1
loadProxies()
}
const closeCreateModal = () => { const closeCreateModal = () => {
showCreateModal.value = false showCreateModal.value = false
createMode.value = 'standard' createMode.value = 'standard'
......
...@@ -186,6 +186,7 @@ ...@@ -186,6 +186,7 @@
:total="pagination.total" :total="pagination.total"
:page-size="pagination.page_size" :page-size="pagination.page_size"
@update:page="handlePageChange" @update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/> />
<!-- Batch Actions --> <!-- Batch Actions -->
...@@ -542,6 +543,8 @@ const pagination = reactive({ ...@@ -542,6 +543,8 @@ const pagination = reactive({
pages: 0 pages: 0
}) })
let abortController: AbortController | null = null
const showDeleteDialog = ref(false) const showDeleteDialog = ref(false)
const showDeleteUnusedDialog = ref(false) const showDeleteUnusedDialog = ref(false)
const deletingCode = ref<RedeemCode | null>(null) const deletingCode = ref<RedeemCode | null>(null)
...@@ -556,21 +559,46 @@ const generateForm = reactive({ ...@@ -556,21 +559,46 @@ const generateForm = reactive({
}) })
const loadCodes = async () => { const loadCodes = async () => {
if (abortController) {
abortController.abort()
}
const currentController = new AbortController()
abortController = currentController
loading.value = true loading.value = true
try { try {
const response = await adminAPI.redeem.list(pagination.page, pagination.page_size, { const response = await adminAPI.redeem.list(
type: filters.type as RedeemCodeType, pagination.page,
status: filters.status as any, pagination.page_size,
search: searchQuery.value || undefined {
}) type: filters.type as RedeemCodeType,
status: filters.status as any,
search: searchQuery.value || undefined
},
{
signal: currentController.signal
}
)
if (currentController.signal.aborted) {
return
}
codes.value = response.items codes.value = response.items
pagination.total = response.total pagination.total = response.total
pagination.pages = response.pages pagination.pages = response.pages
} catch (error) { } catch (error: any) {
if (
currentController.signal.aborted ||
error?.name === 'AbortError' ||
error?.code === 'ERR_CANCELED'
) {
return
}
appStore.showError(t('admin.redeem.failedToLoad')) appStore.showError(t('admin.redeem.failedToLoad'))
console.error('Error loading redeem codes:', error) console.error('Error loading redeem codes:', error)
} finally { } finally {
loading.value = false if (abortController === currentController && !currentController.signal.aborted) {
loading.value = false
abortController = null
}
} }
} }
...@@ -588,6 +616,12 @@ const handlePageChange = (page: number) => { ...@@ -588,6 +616,12 @@ const handlePageChange = (page: number) => {
loadCodes() loadCodes()
} }
const handlePageSizeChange = (pageSize: number) => {
pagination.page_size = pageSize
pagination.page = 1
loadCodes()
}
const handleGenerateCodes = async () => { const handleGenerateCodes = async () => {
// 订阅类型必须选择分组 // 订阅类型必须选择分组
if (generateForm.type === 'subscription' && !generateForm.group_id) { if (generateForm.type === 'subscription' && !generateForm.group_id) {
......
...@@ -316,18 +316,23 @@ ...@@ -316,18 +316,23 @@
:total="pagination.total" :total="pagination.total"
:page-size="pagination.page_size" :page-size="pagination.page_size"
@update:page="handlePageChange" @update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/> />
</template> </template>
</TablePageLayout> </TablePageLayout>
<!-- Assign Subscription Modal --> <!-- Assign Subscription Modal -->
<Modal <BaseDialog
:show="showAssignModal" :show="showAssignModal"
:title="t('admin.subscriptions.assignSubscription')" :title="t('admin.subscriptions.assignSubscription')"
size="lg" width="normal"
@close="closeAssignModal" @close="closeAssignModal"
> >
<form @submit.prevent="handleAssignSubscription" class="space-y-5"> <form
id="assign-subscription-form"
@submit.prevent="handleAssignSubscription"
class="space-y-5"
>
<div> <div>
<label class="input-label">{{ t('admin.subscriptions.form.user') }}</label> <label class="input-label">{{ t('admin.subscriptions.form.user') }}</label>
<Select <Select
...@@ -351,12 +356,18 @@ ...@@ -351,12 +356,18 @@
<input v-model.number="assignForm.validity_days" type="number" min="1" class="input" /> <input v-model.number="assignForm.validity_days" type="number" min="1" class="input" />
<p class="input-hint">{{ t('admin.subscriptions.validityHint') }}</p> <p class="input-hint">{{ t('admin.subscriptions.validityHint') }}</p>
</div> </div>
</form>
<div class="flex justify-end gap-3 pt-4"> <template #footer>
<div class="flex justify-end gap-3">
<button @click="closeAssignModal" type="button" class="btn btn-secondary"> <button @click="closeAssignModal" type="button" class="btn btn-secondary">
{{ t('common.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button type="submit" :disabled="submitting" class="btn btn-primary"> <button
type="submit"
form="assign-subscription-form"
:disabled="submitting"
class="btn btn-primary"
>
<svg <svg
v-if="submitting" v-if="submitting"
class="-ml-1 mr-2 h-4 w-4 animate-spin" class="-ml-1 mr-2 h-4 w-4 animate-spin"
...@@ -380,18 +391,19 @@ ...@@ -380,18 +391,19 @@
{{ submitting ? t('admin.subscriptions.assigning') : t('admin.subscriptions.assign') }} {{ submitting ? t('admin.subscriptions.assigning') : t('admin.subscriptions.assign') }}
</button> </button>
</div> </div>
</form> </template>
</Modal> </BaseDialog>
<!-- Extend Subscription Modal --> <!-- Extend Subscription Modal -->
<Modal <BaseDialog
:show="showExtendModal" :show="showExtendModal"
:title="t('admin.subscriptions.extendSubscription')" :title="t('admin.subscriptions.extendSubscription')"
size="md" width="narrow"
@close="closeExtendModal" @close="closeExtendModal"
> >
<form <form
v-if="extendingSubscription" v-if="extendingSubscription"
id="extend-subscription-form"
@submit.prevent="handleExtendSubscription" @submit.prevent="handleExtendSubscription"
class="space-y-5" class="space-y-5"
> >
...@@ -417,17 +429,23 @@ ...@@ -417,17 +429,23 @@
<label class="input-label">{{ t('admin.subscriptions.form.extendDays') }}</label> <label class="input-label">{{ t('admin.subscriptions.form.extendDays') }}</label>
<input v-model.number="extendForm.days" type="number" min="1" required class="input" /> <input v-model.number="extendForm.days" type="number" min="1" required class="input" />
</div> </div>
</form>
<div class="flex justify-end gap-3 pt-4"> <template #footer>
<div v-if="extendingSubscription" class="flex justify-end gap-3">
<button @click="closeExtendModal" type="button" class="btn btn-secondary"> <button @click="closeExtendModal" type="button" class="btn btn-secondary">
{{ t('common.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button type="submit" :disabled="submitting" class="btn btn-primary"> <button
type="submit"
form="extend-subscription-form"
:disabled="submitting"
class="btn btn-primary"
>
{{ submitting ? t('admin.subscriptions.extending') : t('admin.subscriptions.extend') }} {{ submitting ? t('admin.subscriptions.extending') : t('admin.subscriptions.extend') }}
</button> </button>
</div> </div>
</form> </template>
</Modal> </BaseDialog>
<!-- Revoke Confirmation Dialog --> <!-- Revoke Confirmation Dialog -->
<ConfirmDialog <ConfirmDialog
...@@ -455,7 +473,7 @@ import AppLayout from '@/components/layout/AppLayout.vue' ...@@ -455,7 +473,7 @@ import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue' import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.vue' import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue' import Pagination from '@/components/common/Pagination.vue'
import Modal from '@/components/common/Modal.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import EmptyState from '@/components/common/EmptyState.vue' import EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue' import Select from '@/components/common/Select.vue'
...@@ -485,6 +503,7 @@ const subscriptions = ref<UserSubscription[]>([]) ...@@ -485,6 +503,7 @@ const subscriptions = ref<UserSubscription[]>([])
const groups = ref<Group[]>([]) const groups = ref<Group[]>([])
const users = ref<User[]>([]) const users = ref<User[]>([])
const loading = ref(false) const loading = ref(false)
let abortController: AbortController | null = null
const filters = reactive({ const filters = reactive({
status: '', status: '',
group_id: '' group_id: ''
...@@ -530,20 +549,36 @@ const subscriptionGroupOptions = computed(() => ...@@ -530,20 +549,36 @@ const subscriptionGroupOptions = computed(() =>
const userOptions = computed(() => users.value.map((u) => ({ value: u.id, label: u.email }))) const userOptions = computed(() => users.value.map((u) => ({ value: u.id, label: u.email })))
const loadSubscriptions = async () => { const loadSubscriptions = async () => {
if (abortController) {
abortController.abort()
}
const requestController = new AbortController()
abortController = requestController
const { signal } = requestController
loading.value = true loading.value = true
try { try {
const response = await adminAPI.subscriptions.list(pagination.page, pagination.page_size, { const response = await adminAPI.subscriptions.list(pagination.page, pagination.page_size, {
status: (filters.status as any) || undefined, status: (filters.status as any) || undefined,
group_id: filters.group_id ? parseInt(filters.group_id) : undefined group_id: filters.group_id ? parseInt(filters.group_id) : undefined
}, {
signal
}) })
if (signal.aborted || abortController !== requestController) return
subscriptions.value = response.items subscriptions.value = response.items
pagination.total = response.total pagination.total = response.total
pagination.pages = response.pages pagination.pages = response.pages
} catch (error) { } catch (error: any) {
if (signal.aborted || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') {
return
}
appStore.showError(t('admin.subscriptions.failedToLoad')) appStore.showError(t('admin.subscriptions.failedToLoad'))
console.error('Error loading subscriptions:', error) console.error('Error loading subscriptions:', error)
} finally { } finally {
loading.value = false if (abortController === requestController) {
loading.value = false
abortController = null
}
} }
} }
...@@ -569,6 +604,12 @@ const handlePageChange = (page: number) => { ...@@ -569,6 +604,12 @@ const handlePageChange = (page: number) => {
loadSubscriptions() loadSubscriptions()
} }
const handlePageSizeChange = (pageSize: number) => {
pagination.page_size = pageSize
pagination.page = 1
loadSubscriptions()
}
const closeAssignModal = () => { const closeAssignModal = () => {
showAssignModal.value = false showAssignModal.value = false
assignForm.user_id = null assignForm.user_id = null
......
...@@ -224,7 +224,7 @@ ...@@ -224,7 +224,7 @@
v-model="filters.api_key_id" v-model="filters.api_key_id"
:options="apiKeyOptions" :options="apiKeyOptions"
:placeholder="t('usage.allApiKeys')" :placeholder="t('usage.allApiKeys')"
:disabled="!selectedUser && apiKeys.length === 0" searchable
@change="applyFilters" @change="applyFilters"
/> />
</div> </div>
...@@ -236,6 +236,7 @@ ...@@ -236,6 +236,7 @@
v-model="filters.model" v-model="filters.model"
:options="modelOptions" :options="modelOptions"
:placeholder="t('admin.usage.allModels')" :placeholder="t('admin.usage.allModels')"
searchable
@change="applyFilters" @change="applyFilters"
/> />
</div> </div>
...@@ -534,6 +535,7 @@ ...@@ -534,6 +535,7 @@
:total="pagination.total" :total="pagination.total"
:page-size="pagination.page_size" :page-size="pagination.page_size"
@update:page="handlePageChange" @update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/> />
</div> </div>
</AppLayout> </AppLayout>
...@@ -666,6 +668,7 @@ const models = ref<string[]>([]) ...@@ -666,6 +668,7 @@ const models = ref<string[]>([])
const accounts = ref<any[]>([]) const accounts = ref<any[]>([])
const groups = ref<any[]>([]) const groups = ref<any[]>([])
const loading = ref(false) const loading = ref(false)
let abortController: AbortController | null = null
// User search state // User search state
const userSearchKeyword = ref('') const userSearchKeyword = ref('')
...@@ -675,7 +678,7 @@ const showUserDropdown = ref(false) ...@@ -675,7 +678,7 @@ const showUserDropdown = ref(false)
const selectedUser = ref<SimpleUser | null>(null) const selectedUser = ref<SimpleUser | null>(null)
let searchTimeout: ReturnType<typeof setTimeout> | null = null let searchTimeout: ReturnType<typeof setTimeout> | null = null
// API Key options computed from selected user's keys // API Key options computed from loaded keys
const apiKeyOptions = computed(() => { const apiKeyOptions = computed(() => {
return [ return [
{ value: null, label: t('usage.allApiKeys') }, { value: null, label: t('usage.allApiKeys') },
...@@ -733,9 +736,19 @@ const groupOptions = computed(() => { ...@@ -733,9 +736,19 @@ const groupOptions = computed(() => {
] ]
}) })
// Helper function to format date in local timezone
const formatLocalDate = (date: Date): string => {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
// Initialize date range immediately
const now = new Date()
const weekAgo = new Date(now)
weekAgo.setDate(weekAgo.getDate() - 6)
// Date range state // Date range state
const startDate = ref('') const startDate = ref(formatLocalDate(weekAgo))
const endDate = ref('') const endDate = ref(formatLocalDate(now))
const filters = ref<AdminUsageQueryParams>({ const filters = ref<AdminUsageQueryParams>({
user_id: undefined, user_id: undefined,
...@@ -749,18 +762,9 @@ const filters = ref<AdminUsageQueryParams>({ ...@@ -749,18 +762,9 @@ const filters = ref<AdminUsageQueryParams>({
end_date: undefined end_date: undefined
}) })
// Initialize default date range (last 7 days) // Initialize filters with date range
const initializeDateRange = () => { filters.value.start_date = startDate.value
const now = new Date() filters.value.end_date = endDate.value
const today = now.toISOString().split('T')[0]
const weekAgo = new Date(now)
weekAgo.setDate(weekAgo.getDate() - 6)
startDate.value = weekAgo.toISOString().split('T')[0]
endDate.value = today
filters.value.start_date = startDate.value
filters.value.end_date = endDate.value
}
// User search with debounce // User search with debounce
const debounceSearchUsers = () => { const debounceSearchUsers = () => {
...@@ -796,7 +800,7 @@ const selectUser = async (user: SimpleUser) => { ...@@ -796,7 +800,7 @@ const selectUser = async (user: SimpleUser) => {
filters.value.api_key_id = undefined filters.value.api_key_id = undefined
// Load API keys for selected user // Load API keys for selected user
await loadApiKeysForUser(user.id) await loadApiKeys(user.id)
applyFilters() applyFilters()
} }
...@@ -807,10 +811,11 @@ const clearUserFilter = () => { ...@@ -807,10 +811,11 @@ const clearUserFilter = () => {
filters.value.user_id = undefined filters.value.user_id = undefined
filters.value.api_key_id = undefined filters.value.api_key_id = undefined
apiKeys.value = [] apiKeys.value = []
loadApiKeys()
applyFilters() applyFilters()
} }
const loadApiKeysForUser = async (userId: number) => { const loadApiKeys = async (userId?: number) => {
try { try {
apiKeys.value = await adminAPI.usage.searchApiKeys(userId) apiKeys.value = await adminAPI.usage.searchApiKeys(userId)
} catch (error) { } catch (error) {
...@@ -863,7 +868,24 @@ const formatCacheTokens = (value: number): string => { ...@@ -863,7 +868,24 @@ const formatCacheTokens = (value: number): string => {
return value.toLocaleString() return value.toLocaleString()
} }
const isAbortError = (error: unknown): boolean => {
if (error instanceof DOMException && error.name === 'AbortError') {
return true
}
if (typeof error === 'object' && error !== null) {
const maybeError = error as { code?: string; name?: string }
return maybeError.code === 'ERR_CANCELED' || maybeError.name === 'CanceledError'
}
return false
}
const loadUsageLogs = async () => { const loadUsageLogs = async () => {
if (abortController) {
abortController.abort()
}
const controller = new AbortController()
abortController = controller
const { signal } = controller
loading.value = true loading.value = true
try { try {
const params: AdminUsageQueryParams = { const params: AdminUsageQueryParams = {
...@@ -872,17 +894,23 @@ const loadUsageLogs = async () => { ...@@ -872,17 +894,23 @@ const loadUsageLogs = async () => {
...filters.value ...filters.value
} }
const response = await adminAPI.usage.list(params) const response = await adminAPI.usage.list(params, { signal })
if (signal.aborted) {
return
}
usageLogs.value = response.items usageLogs.value = response.items
pagination.value.total = response.total pagination.value.total = response.total
pagination.value.pages = response.pages pagination.value.pages = response.pages
// Extract models from loaded logs for filter options
extractModelsFromLogs()
} catch (error) { } catch (error) {
if (signal.aborted || isAbortError(error)) {
return
}
appStore.showError(t('usage.failedToLoad')) appStore.showError(t('usage.failedToLoad'))
} finally { } finally {
loading.value = false if (!signal.aborted && abortController === controller) {
loading.value = false
}
} }
} }
...@@ -944,27 +972,40 @@ const applyFilters = () => { ...@@ -944,27 +972,40 @@ const applyFilters = () => {
// Load filter options // Load filter options
const loadFilterOptions = async () => { const loadFilterOptions = async () => {
try { try {
// Load accounts const [accountsResponse, groupsResponse] = await Promise.all([
const accountsResponse = await adminAPI.accounts.list(1, 1000) adminAPI.accounts.list(1, 1000),
adminAPI.groups.list(1, 1000)
])
accounts.value = accountsResponse.items || [] accounts.value = accountsResponse.items || []
// Load groups
const groupsResponse = await adminAPI.groups.list(1, 1000)
groups.value = groupsResponse.items || [] groups.value = groupsResponse.items || []
} catch (error) { } catch (error) {
console.error('Failed to load filter options:', error) console.error('Failed to load filter options:', error)
} }
await loadModelOptions()
} }
// Extract unique models from usage logs const loadModelOptions = async () => {
const extractModelsFromLogs = () => { try {
const uniqueModels = new Set<string>() const endDate = new Date()
usageLogs.value.forEach(log => { const startDateRange = new Date(endDate)
if (log.model) { startDateRange.setDate(startDateRange.getDate() - 29)
uniqueModels.add(log.model) // Use local timezone instead of UTC
} const endDateStr = `${endDate.getFullYear()}-${String(endDate.getMonth() + 1).padStart(2, '0')}-${String(endDate.getDate()).padStart(2, '0')}`
}) const startDateStr = `${startDateRange.getFullYear()}-${String(startDateRange.getMonth() + 1).padStart(2, '0')}-${String(startDateRange.getDate()).padStart(2, '0')}`
models.value = Array.from(uniqueModels).sort() const response = await adminAPI.dashboard.getModelStats({
start_date: startDateStr,
end_date: endDateStr
})
const uniqueModels = new Set<string>()
response.models?.forEach((stat) => {
if (stat.model) {
uniqueModels.add(stat.model)
}
})
models.value = Array.from(uniqueModels).sort()
} catch (error) {
console.error('Failed to load model options:', error)
}
} }
const resetFilters = () => { const resetFilters = () => {
...@@ -985,8 +1026,15 @@ const resetFilters = () => { ...@@ -985,8 +1026,15 @@ const resetFilters = () => {
} }
granularity.value = 'day' granularity.value = 'day'
// Reset date range to default (last 7 days) // Reset date range to default (last 7 days)
initializeDateRange() const now = new Date()
const weekAgo = new Date(now)
weekAgo.setDate(weekAgo.getDate() - 6)
startDate.value = formatLocalDate(weekAgo)
endDate.value = formatLocalDate(now)
filters.value.start_date = startDate.value
filters.value.end_date = endDate.value
pagination.value.page = 1 pagination.value.page = 1
loadApiKeys()
loadUsageLogs() loadUsageLogs()
loadUsageStats() loadUsageStats()
loadChartData() loadChartData()
...@@ -997,6 +1045,12 @@ const handlePageChange = (page: number) => { ...@@ -997,6 +1045,12 @@ const handlePageChange = (page: number) => {
loadUsageLogs() loadUsageLogs()
} }
const handlePageSizeChange = (pageSize: number) => {
pagination.value.page_size = pageSize
pagination.value.page = 1
loadUsageLogs()
}
const exportToCSV = () => { const exportToCSV = () => {
if (usageLogs.value.length === 0) { if (usageLogs.value.length === 0) {
appStore.showWarning(t('usage.noDataToExport')) appStore.showWarning(t('usage.noDataToExport'))
...@@ -1070,8 +1124,8 @@ const hideTooltip = () => { ...@@ -1070,8 +1124,8 @@ const hideTooltip = () => {
} }
onMounted(() => { onMounted(() => {
initializeDateRange()
loadFilterOptions() loadFilterOptions()
loadApiKeys()
loadUsageLogs() loadUsageLogs()
loadUsageStats() loadUsageStats()
loadChartData() loadChartData()
...@@ -1083,5 +1137,8 @@ onUnmounted(() => { ...@@ -1083,5 +1137,8 @@ onUnmounted(() => {
if (searchTimeout) { if (searchTimeout) {
clearTimeout(searchTimeout) clearTimeout(searchTimeout)
} }
if (abortController) {
abortController.abort()
}
}) })
</script> </script>
This diff is collapsed.
...@@ -39,6 +39,7 @@ ...@@ -39,6 +39,7 @@
v-model="formData.email" v-model="formData.email"
type="email" type="email"
required required
autofocus
autocomplete="email" autocomplete="email"
:disabled="isLoading" :disabled="isLoading"
class="input pl-11" class="input pl-11"
......
...@@ -66,6 +66,7 @@ ...@@ -66,6 +66,7 @@
v-model="formData.email" v-model="formData.email"
type="email" type="email"
required required
autofocus
autocomplete="email" autocomplete="email"
:disabled="isLoading" :disabled="isLoading"
class="input pl-11" class="input pl-11"
......
...@@ -563,13 +563,13 @@ const installing = ref(false) ...@@ -563,13 +563,13 @@ const installing = ref(false)
const confirmPassword = ref('') const confirmPassword = ref('')
const serviceReady = ref(false) const serviceReady = ref(false)
// Get current server port from browser location (set by install.sh) // Default server port
const getCurrentPort = (): number => { const getCurrentPort = (): number => {
const port = window.location.port const port = window.location.port
if (port) { if (port) {
return parseInt(port, 10) return parseInt(port, 10)
} }
// Default port based on protocol
return window.location.protocol === 'https:' ? 443 : 80 return window.location.protocol === 'https:' ? 443 : 80
} }
...@@ -674,42 +674,35 @@ async function performInstall() { ...@@ -674,42 +674,35 @@ async function performInstall() {
// Wait for service to restart and become available // Wait for service to restart and become available
async function waitForServiceRestart() { async function waitForServiceRestart() {
const maxAttempts = 30 // 30 attempts, ~30 seconds max const maxAttempts = 60 // Increase to 60 attempts, ~60 seconds max
const interval = 1000 // 1 second between attempts const interval = 1000 // 1 second between attempts
// Wait a moment for the service to start restarting // Wait a moment for the service to start restarting
await new Promise((resolve) => setTimeout(resolve, 2000)) await new Promise((resolve) => setTimeout(resolve, 3000))
for (let attempt = 0; attempt < maxAttempts; attempt++) { for (let attempt = 0; attempt < maxAttempts; attempt++) {
try { try {
// Try to access the health endpoint // Use setup status endpoint as it tells us the real mode
const response = await fetch('/health', { // Service might return 404 or connection refused while restarting
const response = await fetch('/setup/status', {
method: 'GET', method: 'GET',
cache: 'no-store' cache: 'no-store'
}) })
if (response.ok) { if (response.ok) {
// Service is up, check if setup is no longer needed const data = await response.json()
const statusResponse = await fetch('/setup/status', { // If needs_setup is false, service has restarted in normal mode
method: 'GET', if (data.data && !data.data.needs_setup) {
cache: 'no-store' serviceReady.value = true
}) // Redirect to login page after a short delay
setTimeout(() => {
if (statusResponse.ok) { window.location.href = '/login'
const data = await statusResponse.json() }, 1500)
// If needs_setup is false, service has restarted in normal mode return
if (data.data && !data.data.needs_setup) {
serviceReady.value = true
// Redirect to login page after a short delay
setTimeout(() => {
window.location.href = '/login'
}, 1500)
return
}
} }
} }
} catch { } catch {
// Service not ready yet, continue polling // Service not ready or network error during restart, continue polling
} }
await new Promise((resolve) => setTimeout(resolve, interval)) await new Promise((resolve) => setTimeout(resolve, interval))
......
This diff is collapsed.
This diff is collapsed.
...@@ -244,6 +244,12 @@ ...@@ -244,6 +244,12 @@
autocomplete="new-password" autocomplete="new-password"
class="input" class="input"
/> />
<p
v-if="passwordForm.new_password && passwordForm.confirm_password && passwordForm.new_password !== passwordForm.confirm_password"
class="input-error-text"
>
{{ t('profile.passwordsNotMatch') }}
</p>
</div> </div>
<div class="flex justify-end pt-4"> <div class="flex justify-end pt-4">
...@@ -392,6 +398,12 @@ const handleChangePassword = async () => { ...@@ -392,6 +398,12 @@ const handleChangePassword = async () => {
} }
const handleUpdateProfile = async () => { const handleUpdateProfile = async () => {
// Basic validation
if (!profileForm.value.username.trim()) {
appStore.showError(t('profile.usernameRequired'))
return
}
updatingProfile.value = true updatingProfile.value = true
try { try {
const updatedUser = await userAPI.updateProfile({ const updatedUser = await userAPI.updateProfile({
......
...@@ -445,6 +445,7 @@ import { ref, computed, onMounted } from 'vue' ...@@ -445,6 +445,7 @@ import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useSubscriptionStore } from '@/stores/subscriptions'
import { redeemAPI, authAPI, type RedeemHistoryItem } from '@/api' import { redeemAPI, authAPI, type RedeemHistoryItem } from '@/api'
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from '@/components/layout/AppLayout.vue'
import { formatDateTime } from '@/utils/format' import { formatDateTime } from '@/utils/format'
...@@ -452,6 +453,7 @@ import { formatDateTime } from '@/utils/format' ...@@ -452,6 +453,7 @@ import { formatDateTime } from '@/utils/format'
const { t } = useI18n() const { t } = useI18n()
const authStore = useAuthStore() const authStore = useAuthStore()
const appStore = useAppStore() const appStore = useAppStore()
const subscriptionStore = useSubscriptionStore()
const user = computed(() => authStore.user) const user = computed(() => authStore.user)
...@@ -544,6 +546,16 @@ const handleRedeem = async () => { ...@@ -544,6 +546,16 @@ const handleRedeem = async () => {
// Refresh user data to get updated balance/concurrency // Refresh user data to get updated balance/concurrency
await authStore.refreshUser() await authStore.refreshUser()
// If subscription type, immediately refresh subscription status
if (result.type === 'subscription') {
try {
await subscriptionStore.fetchActiveSubscriptions(true) // force refresh
} catch (error) {
console.error('Failed to refresh subscriptions after redeem:', error)
appStore.showWarning(t('redeem.subscriptionRefreshFailed'))
}
}
// Clear the input // Clear the input
redeemCode.value = '' redeemCode.value = ''
......
This diff is collapsed.
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment