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

Merge branch 'main' into test-dev

parents 3a7d3387 c01db6b1
......@@ -307,6 +307,35 @@
@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 {
@apply fixed right-4 top-4 z-[100];
......
......@@ -60,7 +60,11 @@ export interface PublicSettings {
export interface AuthResponse {
access_token: string
token_type: string
user: User
user: User & { run_mode?: 'standard' | 'simple' }
}
export interface CurrentUserResponse extends User {
run_mode?: 'standard' | 'simple'
}
// ==================== Subscription Types ====================
......
This diff is collapsed.
......@@ -407,10 +407,20 @@ const trendData = ref<TrendDataPoint[]>([])
const modelStats = ref<ModelStat[]>([])
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
const granularity = ref<'day' | 'hour'>('day')
const startDate = ref('')
const endDate = ref('')
const startDate = ref(formatLocalDate(weekAgo))
const endDate = ref(formatLocalDate(now))
// Granularity options for Select component
const granularityOptions = computed(() => [
......@@ -597,18 +607,6 @@ const onDateRangeChange = (range: {
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
const loadDashboardStats = async () => {
loading.value = true
......@@ -649,7 +647,6 @@ const loadChartData = async () => {
onMounted(() => {
loadDashboardStats()
initializeDateRange()
loadChartData()
})
</script>
......
......@@ -223,18 +223,19 @@
:total="pagination.total"
:page-size="pagination.page_size"
@update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/>
</template>
</TablePageLayout>
<!-- Create Group Modal -->
<Modal
<BaseDialog
:show="showCreateModal"
:title="t('admin.groups.createGroup')"
size="lg"
width="normal"
@close="closeCreateModal"
>
<form @submit.prevent="handleCreateGroup" class="space-y-5">
<form id="create-group-form" @submit.prevent="handleCreateGroup" class="space-y-5">
<div>
<label class="input-label">{{ t('admin.groups.form.name') }}</label>
<input
......@@ -271,34 +272,66 @@
/>
<p class="input-hint">{{ t('admin.groups.rateMultiplierHint') }}</p>
</div>
<div v-if="createForm.subscription_type !== 'subscription'" class="flex items-center gap-3">
<button
type="button"
@click="createForm.is_exclusive = !createForm.is_exclusive"
:class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
createForm.is_exclusive ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
]"
>
<span
<div v-if="createForm.subscription_type !== 'subscription'">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.form.exclusive') }}
</label>
<!-- Help Tooltip -->
<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"
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="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
createForm.is_exclusive ? 'translate-x-6' : 'translate-x-1'
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
createForm.is_exclusive ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
]"
/>
</button>
<label class="text-sm text-gray-700 dark:text-gray-300">
{{ t('admin.groups.exclusiveHint') }}
</label>
>
<span
:class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
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>
<!-- Subscription Configuration -->
<div class="mt-4 border-t pt-4">
<h4 class="mb-4 text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.groups.subscription.title') }}
</h4>
<div class="mb-4">
<div>
<label class="input-label">{{ t('admin.groups.subscription.type') }}</label>
<Select v-model="createForm.subscription_type" :options="subscriptionTypeOptions" />
<p class="input-hint">{{ t('admin.groups.subscription.typeHint') }}</p>
......@@ -345,11 +378,19 @@
</div>
</div>
</form>
<template #footer>
<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">
<button
type="submit"
form="create-group-form"
:disabled="submitting"
class="btn btn-primary"
>
<svg
v-if="submitting"
class="-ml-1 mr-2 h-4 w-4 animate-spin"
......@@ -373,17 +414,22 @@
{{ submitting ? t('admin.groups.creating') : t('common.create') }}
</button>
</div>
</form>
</Modal>
</template>
</BaseDialog>
<!-- Edit Group Modal -->
<Modal
<BaseDialog
:show="showEditModal"
:title="t('admin.groups.editGroup')"
size="lg"
width="normal"
@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>
<label class="input-label">{{ t('admin.groups.form.name') }}</label>
<input v-model="editForm.name" type="text" required class="input" />
......@@ -408,25 +454,61 @@
class="input"
/>
</div>
<div v-if="editForm.subscription_type !== 'subscription'" class="flex items-center gap-3">
<button
type="button"
@click="editForm.is_exclusive = !editForm.is_exclusive"
:class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
editForm.is_exclusive ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
]"
>
<span
<div v-if="editForm.subscription_type !== 'subscription'">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.form.exclusive') }}
</label>
<!-- Help Tooltip -->
<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"
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="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
editForm.is_exclusive ? 'translate-x-6' : 'translate-x-1'
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
editForm.is_exclusive ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
]"
/>
</button>
<label class="text-sm text-gray-700 dark:text-gray-300">
{{ t('admin.groups.exclusiveHint') }}
</label>
>
<span
:class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
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>
<label class="input-label">{{ t('admin.groups.form.status') }}</label>
......@@ -435,11 +517,7 @@
<!-- Subscription Configuration -->
<div class="mt-4 border-t pt-4">
<h4 class="mb-4 text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.groups.subscription.title') }}
</h4>
<div class="mb-4">
<div>
<label class="input-label">{{ t('admin.groups.subscription.type') }}</label>
<Select
v-model="editForm.subscription_type"
......@@ -490,11 +568,19 @@
</div>
</div>
</form>
<template #footer>
<div class="flex justify-end gap-3 pt-4">
<button @click="closeEditModal" type="button" class="btn btn-secondary">
{{ t('common.cancel') }}
</button>
<button type="submit" :disabled="submitting" class="btn btn-primary">
<button
type="submit"
form="edit-group-form"
:disabled="submitting"
class="btn btn-primary"
>
<svg
v-if="submitting"
class="-ml-1 mr-2 h-4 w-4 animate-spin"
......@@ -518,8 +604,8 @@
{{ submitting ? t('admin.groups.updating') : t('common.update') }}
</button>
</div>
</form>
</Modal>
</template>
</BaseDialog>
<!-- Delete Confirmation Dialog -->
<ConfirmDialog
......@@ -546,7 +632,7 @@ import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.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 EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue'
......@@ -616,6 +702,8 @@ const pagination = reactive({
pages: 0
})
let abortController: AbortController | null = null
const showCreateModal = ref(false)
const showEditModal = ref(false)
const showDeleteDialog = ref(false)
......@@ -660,21 +748,33 @@ const deleteConfirmMessage = computed(() => {
})
const loadGroups = async () => {
if (abortController) {
abortController.abort()
}
const currentController = new AbortController()
abortController = currentController
const { signal } = currentController
loading.value = true
try {
const response = await adminAPI.groups.list(pagination.page, pagination.page_size, {
platform: (filters.platform as GroupPlatform) || undefined,
status: filters.status as any,
is_exclusive: filters.is_exclusive ? filters.is_exclusive === 'true' : undefined
})
}, { signal })
if (signal.aborted) return
groups.value = response.items
pagination.total = response.total
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'))
console.error('Error loading groups:', error)
} finally {
loading.value = false
if (abortController === currentController && !signal.aborted) {
loading.value = false
}
}
}
......@@ -683,6 +783,12 @@ const handlePageChange = (page: number) => {
loadGroups()
}
const handlePageSizeChange = (pageSize: number) => {
pagination.page_size = pageSize
pagination.page = 1
loadGroups()
}
const closeCreateModal = () => {
showCreateModal.value = false
createForm.name = ''
......
......@@ -209,15 +209,16 @@
:total="pagination.total"
:page-size="pagination.page_size"
@update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/>
</template>
</TablePageLayout>
<!-- Create Proxy Modal -->
<Modal
<BaseDialog
:show="showCreateModal"
:title="t('admin.proxies.createProxy')"
size="lg"
width="normal"
@close="closeCreateModal"
>
<!-- Tab Switch -->
......@@ -271,7 +272,12 @@
</div>
<!-- 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>
<label class="input-label">{{ t('admin.proxies.name') }}</label>
<input
......@@ -329,34 +335,6 @@
/>
</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>
<!-- Batch Add Form -->
......@@ -435,11 +413,44 @@
</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">
{{ t('common.cancel') }}
</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"
type="button"
:disabled="submitting || batchParseResult.valid === 0"
......@@ -472,17 +483,22 @@
}}
</button>
</div>
</div>
</Modal>
</template>
</BaseDialog>
<!-- Edit Proxy Modal -->
<Modal
<BaseDialog
:show="showEditModal"
:title="t('admin.proxies.editProxy')"
size="lg"
width="normal"
@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>
<label class="input-label">{{ t('admin.proxies.name') }}</label>
<input v-model="editForm.name" type="text" required class="input" />
......@@ -526,11 +542,20 @@
<Select v-model="editForm.status" :options="editStatusOptions" />
</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">
{{ t('common.cancel') }}
</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
v-if="submitting"
class="-ml-1 mr-2 h-4 w-4 animate-spin"
......@@ -554,8 +579,8 @@
{{ submitting ? t('admin.proxies.updating') : t('common.update') }}
</button>
</div>
</form>
</Modal>
</template>
</BaseDialog>
<!-- Delete Confirmation Dialog -->
<ConfirmDialog
......@@ -582,7 +607,7 @@ import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.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 EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue'
......@@ -682,22 +707,44 @@ const editForm = reactive({
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 () => {
if (abortController) {
abortController.abort()
}
const currentAbortController = new AbortController()
abortController = currentAbortController
loading.value = true
try {
const response = await adminAPI.proxies.list(pagination.page, pagination.page_size, {
protocol: filters.protocol || undefined,
status: filters.status as any,
search: searchQuery.value || undefined
})
}, { signal: currentAbortController.signal })
if (currentAbortController.signal.aborted || abortController !== currentAbortController) {
return
}
proxies.value = response.items
pagination.total = response.total
pagination.pages = response.pages
} catch (error) {
if (isAbortError(error)) {
return
}
appStore.showError(t('admin.proxies.failedToLoad'))
console.error('Error loading proxies:', error)
} finally {
loading.value = false
if (abortController === currentAbortController) {
loading.value = false
abortController = null
}
}
}
......@@ -715,6 +762,12 @@ const handlePageChange = (page: number) => {
loadProxies()
}
const handlePageSizeChange = (pageSize: number) => {
pagination.page_size = pageSize
pagination.page = 1
loadProxies()
}
const closeCreateModal = () => {
showCreateModal.value = false
createMode.value = 'standard'
......
......@@ -186,6 +186,7 @@
:total="pagination.total"
:page-size="pagination.page_size"
@update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/>
<!-- Batch Actions -->
......@@ -542,6 +543,8 @@ const pagination = reactive({
pages: 0
})
let abortController: AbortController | null = null
const showDeleteDialog = ref(false)
const showDeleteUnusedDialog = ref(false)
const deletingCode = ref<RedeemCode | null>(null)
......@@ -556,21 +559,46 @@ const generateForm = reactive({
})
const loadCodes = async () => {
if (abortController) {
abortController.abort()
}
const currentController = new AbortController()
abortController = currentController
loading.value = true
try {
const response = await adminAPI.redeem.list(pagination.page, pagination.page_size, {
type: filters.type as RedeemCodeType,
status: filters.status as any,
search: searchQuery.value || undefined
})
const response = await adminAPI.redeem.list(
pagination.page,
pagination.page_size,
{
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
pagination.total = response.total
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'))
console.error('Error loading redeem codes:', error)
} finally {
loading.value = false
if (abortController === currentController && !currentController.signal.aborted) {
loading.value = false
abortController = null
}
}
}
......@@ -588,6 +616,12 @@ const handlePageChange = (page: number) => {
loadCodes()
}
const handlePageSizeChange = (pageSize: number) => {
pagination.page_size = pageSize
pagination.page = 1
loadCodes()
}
const handleGenerateCodes = async () => {
// 订阅类型必须选择分组
if (generateForm.type === 'subscription' && !generateForm.group_id) {
......
......@@ -316,18 +316,23 @@
:total="pagination.total"
:page-size="pagination.page_size"
@update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/>
</template>
</TablePageLayout>
<!-- Assign Subscription Modal -->
<Modal
<BaseDialog
:show="showAssignModal"
:title="t('admin.subscriptions.assignSubscription')"
size="lg"
width="normal"
@close="closeAssignModal"
>
<form @submit.prevent="handleAssignSubscription" class="space-y-5">
<form
id="assign-subscription-form"
@submit.prevent="handleAssignSubscription"
class="space-y-5"
>
<div>
<label class="input-label">{{ t('admin.subscriptions.form.user') }}</label>
<Select
......@@ -351,12 +356,18 @@
<input v-model.number="assignForm.validity_days" type="number" min="1" class="input" />
<p class="input-hint">{{ t('admin.subscriptions.validityHint') }}</p>
</div>
<div class="flex justify-end gap-3 pt-4">
</form>
<template #footer>
<div class="flex justify-end gap-3">
<button @click="closeAssignModal" type="button" class="btn btn-secondary">
{{ t('common.cancel') }}
</button>
<button type="submit" :disabled="submitting" class="btn btn-primary">
<button
type="submit"
form="assign-subscription-form"
:disabled="submitting"
class="btn btn-primary"
>
<svg
v-if="submitting"
class="-ml-1 mr-2 h-4 w-4 animate-spin"
......@@ -380,18 +391,19 @@
{{ submitting ? t('admin.subscriptions.assigning') : t('admin.subscriptions.assign') }}
</button>
</div>
</form>
</Modal>
</template>
</BaseDialog>
<!-- Extend Subscription Modal -->
<Modal
<BaseDialog
:show="showExtendModal"
:title="t('admin.subscriptions.extendSubscription')"
size="md"
width="narrow"
@close="closeExtendModal"
>
<form
v-if="extendingSubscription"
id="extend-subscription-form"
@submit.prevent="handleExtendSubscription"
class="space-y-5"
>
......@@ -417,17 +429,23 @@
<label class="input-label">{{ t('admin.subscriptions.form.extendDays') }}</label>
<input v-model.number="extendForm.days" type="number" min="1" required class="input" />
</div>
<div class="flex justify-end gap-3 pt-4">
</form>
<template #footer>
<div v-if="extendingSubscription" class="flex justify-end gap-3">
<button @click="closeExtendModal" type="button" class="btn btn-secondary">
{{ t('common.cancel') }}
</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') }}
</button>
</div>
</form>
</Modal>
</template>
</BaseDialog>
<!-- Revoke Confirmation Dialog -->
<ConfirmDialog
......@@ -455,7 +473,7 @@ import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.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 EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue'
......@@ -485,6 +503,7 @@ const subscriptions = ref<UserSubscription[]>([])
const groups = ref<Group[]>([])
const users = ref<User[]>([])
const loading = ref(false)
let abortController: AbortController | null = null
const filters = reactive({
status: '',
group_id: ''
......@@ -530,20 +549,36 @@ const subscriptionGroupOptions = computed(() =>
const userOptions = computed(() => users.value.map((u) => ({ value: u.id, label: u.email })))
const loadSubscriptions = async () => {
if (abortController) {
abortController.abort()
}
const requestController = new AbortController()
abortController = requestController
const { signal } = requestController
loading.value = true
try {
const response = await adminAPI.subscriptions.list(pagination.page, pagination.page_size, {
status: (filters.status as any) || undefined,
group_id: filters.group_id ? parseInt(filters.group_id) : undefined
}, {
signal
})
if (signal.aborted || abortController !== requestController) return
subscriptions.value = response.items
pagination.total = response.total
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'))
console.error('Error loading subscriptions:', error)
} finally {
loading.value = false
if (abortController === requestController) {
loading.value = false
abortController = null
}
}
}
......@@ -569,6 +604,12 @@ const handlePageChange = (page: number) => {
loadSubscriptions()
}
const handlePageSizeChange = (pageSize: number) => {
pagination.page_size = pageSize
pagination.page = 1
loadSubscriptions()
}
const closeAssignModal = () => {
showAssignModal.value = false
assignForm.user_id = null
......
......@@ -224,7 +224,7 @@
v-model="filters.api_key_id"
:options="apiKeyOptions"
:placeholder="t('usage.allApiKeys')"
:disabled="!selectedUser && apiKeys.length === 0"
searchable
@change="applyFilters"
/>
</div>
......@@ -236,6 +236,7 @@
v-model="filters.model"
:options="modelOptions"
:placeholder="t('admin.usage.allModels')"
searchable
@change="applyFilters"
/>
</div>
......@@ -534,6 +535,7 @@
:total="pagination.total"
:page-size="pagination.page_size"
@update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/>
</div>
</AppLayout>
......@@ -666,6 +668,7 @@ const models = ref<string[]>([])
const accounts = ref<any[]>([])
const groups = ref<any[]>([])
const loading = ref(false)
let abortController: AbortController | null = null
// User search state
const userSearchKeyword = ref('')
......@@ -675,7 +678,7 @@ const showUserDropdown = ref(false)
const selectedUser = ref<SimpleUser | 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(() => {
return [
{ value: null, label: t('usage.allApiKeys') },
......@@ -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
const startDate = ref('')
const endDate = ref('')
const startDate = ref(formatLocalDate(weekAgo))
const endDate = ref(formatLocalDate(now))
const filters = ref<AdminUsageQueryParams>({
user_id: undefined,
......@@ -749,18 +762,9 @@ const filters = ref<AdminUsageQueryParams>({
end_date: undefined
})
// Initialize default date range (last 7 days)
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
filters.value.start_date = startDate.value
filters.value.end_date = endDate.value
}
// Initialize filters with date range
filters.value.start_date = startDate.value
filters.value.end_date = endDate.value
// User search with debounce
const debounceSearchUsers = () => {
......@@ -796,7 +800,7 @@ const selectUser = async (user: SimpleUser) => {
filters.value.api_key_id = undefined
// Load API keys for selected user
await loadApiKeysForUser(user.id)
await loadApiKeys(user.id)
applyFilters()
}
......@@ -807,10 +811,11 @@ const clearUserFilter = () => {
filters.value.user_id = undefined
filters.value.api_key_id = undefined
apiKeys.value = []
loadApiKeys()
applyFilters()
}
const loadApiKeysForUser = async (userId: number) => {
const loadApiKeys = async (userId?: number) => {
try {
apiKeys.value = await adminAPI.usage.searchApiKeys(userId)
} catch (error) {
......@@ -863,7 +868,24 @@ const formatCacheTokens = (value: number): string => {
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 () => {
if (abortController) {
abortController.abort()
}
const controller = new AbortController()
abortController = controller
const { signal } = controller
loading.value = true
try {
const params: AdminUsageQueryParams = {
......@@ -872,17 +894,23 @@ const loadUsageLogs = async () => {
...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
pagination.value.total = response.total
pagination.value.pages = response.pages
// Extract models from loaded logs for filter options
extractModelsFromLogs()
} catch (error) {
if (signal.aborted || isAbortError(error)) {
return
}
appStore.showError(t('usage.failedToLoad'))
} finally {
loading.value = false
if (!signal.aborted && abortController === controller) {
loading.value = false
}
}
}
......@@ -944,27 +972,40 @@ const applyFilters = () => {
// Load filter options
const loadFilterOptions = async () => {
try {
// Load accounts
const accountsResponse = await adminAPI.accounts.list(1, 1000)
const [accountsResponse, groupsResponse] = await Promise.all([
adminAPI.accounts.list(1, 1000),
adminAPI.groups.list(1, 1000)
])
accounts.value = accountsResponse.items || []
// Load groups
const groupsResponse = await adminAPI.groups.list(1, 1000)
groups.value = groupsResponse.items || []
} catch (error) {
console.error('Failed to load filter options:', error)
}
await loadModelOptions()
}
// Extract unique models from usage logs
const extractModelsFromLogs = () => {
const uniqueModels = new Set<string>()
usageLogs.value.forEach(log => {
if (log.model) {
uniqueModels.add(log.model)
}
})
models.value = Array.from(uniqueModels).sort()
const loadModelOptions = async () => {
try {
const endDate = new Date()
const startDateRange = new Date(endDate)
startDateRange.setDate(startDateRange.getDate() - 29)
// 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')}`
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 = () => {
......@@ -985,8 +1026,15 @@ const resetFilters = () => {
}
granularity.value = 'day'
// 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
loadApiKeys()
loadUsageLogs()
loadUsageStats()
loadChartData()
......@@ -997,6 +1045,12 @@ const handlePageChange = (page: number) => {
loadUsageLogs()
}
const handlePageSizeChange = (pageSize: number) => {
pagination.value.page_size = pageSize
pagination.value.page = 1
loadUsageLogs()
}
const exportToCSV = () => {
if (usageLogs.value.length === 0) {
appStore.showWarning(t('usage.noDataToExport'))
......@@ -1070,8 +1124,8 @@ const hideTooltip = () => {
}
onMounted(() => {
initializeDateRange()
loadFilterOptions()
loadApiKeys()
loadUsageLogs()
loadUsageStats()
loadChartData()
......@@ -1083,5 +1137,8 @@ onUnmounted(() => {
if (searchTimeout) {
clearTimeout(searchTimeout)
}
if (abortController) {
abortController.abort()
}
})
</script>
This diff is collapsed.
......@@ -39,6 +39,7 @@
v-model="formData.email"
type="email"
required
autofocus
autocomplete="email"
:disabled="isLoading"
class="input pl-11"
......
......@@ -66,6 +66,7 @@
v-model="formData.email"
type="email"
required
autofocus
autocomplete="email"
:disabled="isLoading"
class="input pl-11"
......
......@@ -563,13 +563,13 @@ const installing = ref(false)
const confirmPassword = ref('')
const serviceReady = ref(false)
// Get current server port from browser location (set by install.sh)
// Default server port
const getCurrentPort = (): number => {
const port = window.location.port
if (port) {
return parseInt(port, 10)
}
// Default port based on protocol
return window.location.protocol === 'https:' ? 443 : 80
}
......@@ -674,42 +674,35 @@ async function performInstall() {
// Wait for service to restart and become available
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
// 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++) {
try {
// Try to access the health endpoint
const response = await fetch('/health', {
// Use setup status endpoint as it tells us the real mode
// Service might return 404 or connection refused while restarting
const response = await fetch('/setup/status', {
method: 'GET',
cache: 'no-store'
})
if (response.ok) {
// Service is up, check if setup is no longer needed
const statusResponse = await fetch('/setup/status', {
method: 'GET',
cache: 'no-store'
})
if (statusResponse.ok) {
const data = await statusResponse.json()
// If needs_setup is false, service has restarted in normal mode
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
}
const data = await response.json()
// If needs_setup is false, service has restarted in normal mode
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 {
// Service not ready yet, continue polling
// Service not ready or network error during restart, continue polling
}
await new Promise((resolve) => setTimeout(resolve, interval))
......
This diff is collapsed.
This diff is collapsed.
......@@ -244,6 +244,12 @@
autocomplete="new-password"
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 class="flex justify-end pt-4">
......@@ -392,6 +398,12 @@ const handleChangePassword = async () => {
}
const handleUpdateProfile = async () => {
// Basic validation
if (!profileForm.value.username.trim()) {
appStore.showError(t('profile.usernameRequired'))
return
}
updatingProfile.value = true
try {
const updatedUser = await userAPI.updateProfile({
......
......@@ -445,6 +445,7 @@ import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { useAppStore } from '@/stores/app'
import { useSubscriptionStore } from '@/stores/subscriptions'
import { redeemAPI, authAPI, type RedeemHistoryItem } from '@/api'
import AppLayout from '@/components/layout/AppLayout.vue'
import { formatDateTime } from '@/utils/format'
......@@ -452,6 +453,7 @@ import { formatDateTime } from '@/utils/format'
const { t } = useI18n()
const authStore = useAuthStore()
const appStore = useAppStore()
const subscriptionStore = useSubscriptionStore()
const user = computed(() => authStore.user)
......@@ -544,6 +546,16 @@ const handleRedeem = async () => {
// Refresh user data to get updated balance/concurrency
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
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