Commit ff06583c authored by song's avatar song
Browse files

Merge branch 'main' into feature/antigravity_auth

parents b0389ca4 fb9d0878
......@@ -198,12 +198,13 @@
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDateTime(value) }}</span>
</template>
<template #cell-actions="{ row, expanded }">
<template #cell-actions="{ row }">
<div class="flex items-center gap-1">
<!-- 主要操作:编辑和删除(始终显示) -->
<!-- Edit Button -->
<button
@click="handleEdit(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
class="flex h-8 w-8 items-center justify-center rounded-lg text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
:title="t('common.edit')"
>
<svg
class="h-4 w-4"
......@@ -218,145 +219,29 @@
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
<span class="text-xs">{{ t('common.edit') }}</span>
</button>
<!-- More Actions Menu Trigger -->
<button
v-if="row.role !== 'admin'"
@click="handleDelete(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
:ref="(el) => setActionButtonRef(row.id, el)"
@click="openActionMenu(row)"
class="action-menu-trigger flex h-8 w-8 items-center justify-center rounded-lg text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-dark-700 dark:hover:text-white"
:class="{ 'bg-gray-100 text-gray-900 dark:bg-dark-700 dark:text-white': activeMenuId === row.id }"
>
<svg
class="h-4 w-4"
class="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
<span class="text-xs">{{ t('common.delete') }}</span>
</button>
<!-- 次要操作:展开时显示 -->
<template v-if="expanded">
<!-- Toggle Status (hidden for admin users) -->
<button
v-if="row.role !== 'admin'"
@click="handleToggleStatus(row)"
:class="[
'flex flex-col items-center gap-0.5 rounded-lg p-1.5 transition-colors',
row.status === 'active'
? 'text-gray-500 hover:bg-orange-50 hover:text-orange-600 dark:hover:bg-orange-900/20 dark:hover:text-orange-400'
: 'text-gray-500 hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400'
]"
>
<svg
v-if="row.status === 'active'"
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
d="M6.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM12.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM18.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0z"
/>
</svg>
<svg
v-else
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="text-xs">{{ row.status === 'active' ? t('admin.users.disable') : t('admin.users.enable') }}</span>
</button>
<!-- Allowed Groups -->
<button
@click="handleAllowedGroups(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z"
/>
</svg>
<span class="text-xs">{{ t('admin.users.groups') }}</span>
</button>
<!-- View API Keys -->
<button
@click="handleViewApiKeys(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-purple-50 hover:text-purple-600 dark:hover:bg-purple-900/20 dark:hover:text-purple-400"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
</svg>
<span class="text-xs">{{ t('admin.users.apiKeys') }}</span>
</button>
<!-- Deposit -->
<button
@click="handleDeposit(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-emerald-50 hover:text-emerald-600 dark:hover:bg-emerald-900/20 dark:hover:text-emerald-400"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<span class="text-xs">{{ t('admin.users.deposit') }}</span>
</button>
<!-- Withdraw -->
<button
@click="handleWithdraw(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14" />
</svg>
<span class="text-xs">{{ t('admin.users.withdraw') }}</span>
</button>
</template>
</button>
</div>
</template>
......@@ -379,18 +264,121 @@
:total="pagination.total"
:page-size="pagination.page_size"
@update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/>
</template>
</TablePageLayout>
<!-- Action Menu (Teleported) -->
<Teleport to="body">
<div
v-if="activeMenuId !== null && menuPosition"
class="action-menu-content fixed z-[9999] w-48 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 dark:bg-dark-800 dark:ring-white/10"
:style="{ top: menuPosition.top + 'px', left: menuPosition.left + 'px' }"
>
<div class="py-1">
<template v-for="user in users" :key="user.id">
<template v-if="user.id === activeMenuId">
<!-- View API Keys -->
<button
@click="handleViewApiKeys(user); closeActionMenu()"
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<svg class="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11.536 16.207l-1.414 1.414a2 2 0 01-2.828 0l-1.414-1.414a2 2 0 010-2.828l-1.414-1.414a2 2 0 010-2.828l1.414-1.414L10.257 6.257A6 6 0 1121 11.257V11.257" />
</svg>
{{ t('admin.users.apiKeys') }}
</button>
<!-- Allowed Groups -->
<button
@click="handleAllowedGroups(user); closeActionMenu()"
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<svg class="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
{{ t('admin.users.groups') }}
</button>
<div class="my-1 border-t border-gray-100 dark:border-dark-700"></div>
<!-- Deposit -->
<button
@click="handleDeposit(user); closeActionMenu()"
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<svg class="h-4 w-4 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
{{ t('admin.users.deposit') }}
</button>
<!-- Withdraw -->
<button
@click="handleWithdraw(user); closeActionMenu()"
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<svg class="h-4 w-4 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12H4" />
</svg>
{{ t('admin.users.withdraw') }}
</button>
<div class="my-1 border-t border-gray-100 dark:border-dark-700"></div>
<!-- Toggle Status (not for admin) -->
<button
v-if="user.role !== 'admin'"
@click="handleToggleStatus(user); closeActionMenu()"
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<svg
v-if="user.status === 'active'"
class="h-4 w-4 text-orange-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
<svg
v-else
class="h-4 w-4 text-green-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ user.status === 'active' ? t('admin.users.disable') : t('admin.users.enable') }}
</button>
<!-- Delete (not for admin) -->
<button
v-if="user.role !== 'admin'"
@click="handleDelete(user); closeActionMenu()"
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
{{ t('common.delete') }}
</button>
</template>
</template>
</div>
</div>
</Teleport>
<!-- Create User Modal -->
<Modal
<BaseDialog
:show="showCreateModal"
:title="t('admin.users.createUser')"
size="lg"
width="normal"
@close="closeCreateModal"
>
<form @submit.prevent="handleCreateUser" class="space-y-5">
<form id="create-user-form" @submit.prevent="handleCreateUser" class="space-y-5">
<div>
<label class="input-label">{{ t('admin.users.email') }}</label>
<input
......@@ -512,12 +500,19 @@
<input v-model.number="createForm.concurrency" type="number" class="input" />
</div>
</div>
</form>
<div class="flex justify-end gap-3 pt-4">
<template #footer>
<div class="flex justify-end gap-3">
<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-user-form"
:disabled="submitting"
class="btn btn-primary"
>
<svg
v-if="submitting"
class="-ml-1 mr-2 h-4 w-4 animate-spin"
......@@ -541,17 +536,22 @@
{{ submitting ? t('admin.users.creating') : t('common.create') }}
</button>
</div>
</form>
</Modal>
</template>
</BaseDialog>
<!-- Edit User Modal -->
<Modal
<BaseDialog
:show="showEditModal"
:title="t('admin.users.editUser')"
size="lg"
width="normal"
@close="closeEditModal"
>
<form v-if="editingUser" @submit.prevent="handleUpdateUser" class="space-y-5">
<form
v-if="editingUser"
id="edit-user-form"
@submit.prevent="handleUpdateUser"
class="space-y-5"
>
<div>
<label class="input-label">{{ t('admin.users.email') }}</label>
<input v-model="editForm.email" type="email" class="input" />
......@@ -664,11 +664,19 @@
<input v-model.number="editForm.concurrency" type="number" class="input" />
</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
type="submit"
form="edit-user-form"
:disabled="submitting"
class="btn btn-primary"
>
<svg
v-if="submitting"
class="-ml-1 mr-2 h-4 w-4 animate-spin"
......@@ -692,14 +700,14 @@
{{ submitting ? t('admin.users.updating') : t('common.update') }}
</button>
</div>
</form>
</Modal>
</template>
</BaseDialog>
<!-- View API Keys Modal -->
<Modal
<BaseDialog
:show="showApiKeysModal"
:title="t('admin.users.userApiKeys')"
size="xl"
width="wide"
@close="closeApiKeysModal"
>
<div v-if="viewingUser" class="space-y-4">
......@@ -828,13 +836,13 @@
</button>
</div>
</template>
</Modal>
</BaseDialog>
<!-- Allowed Groups Modal -->
<Modal
<BaseDialog
:show="showAllowedGroupsModal"
:title="t('admin.users.setAllowedGroups')"
size="lg"
width="normal"
@close="closeAllowedGroupsModal"
>
<div v-if="allowedGroupsUser" class="space-y-4">
......@@ -994,16 +1002,21 @@
</button>
</div>
</template>
</Modal>
</BaseDialog>
<!-- Deposit/Withdraw Modal -->
<Modal
<BaseDialog
:show="showBalanceModal"
:title="balanceOperation === 'add' ? t('admin.users.deposit') : t('admin.users.withdraw')"
size="md"
width="narrow"
@close="closeBalanceModal"
>
<form v-if="balanceUser" @submit.prevent="handleBalanceSubmit" class="space-y-5">
<form
v-if="balanceUser"
id="balance-form"
@submit.prevent="handleBalanceSubmit"
class="space-y-5"
>
<div class="flex items-center gap-3 rounded-xl bg-gray-50 p-4 dark:bg-dark-700">
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30"
......@@ -1098,12 +1111,16 @@
</div>
</div>
<div class="flex justify-end gap-3 pt-4">
</form>
<template #footer>
<div class="flex justify-end gap-3">
<button @click="closeBalanceModal" type="button" class="btn btn-secondary">
{{ t('common.cancel') }}
</button>
<button
type="submit"
form="balance-form"
:disabled="
balanceSubmitting ||
!balanceForm.amount ||
......@@ -1148,8 +1165,8 @@
}}
</button>
</div>
</form>
</Modal>
</template>
</BaseDialog>
<!-- Delete Confirmation Dialog -->
<ConfirmDialog
......@@ -1166,7 +1183,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, onUnmounted, type ComponentPublicInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useClipboard } from '@/composables/useClipboard'
......@@ -1181,7 +1198,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'
......@@ -1244,6 +1261,63 @@ const viewingUser = ref<User | null>(null)
const userApiKeys = ref<ApiKey[]>([])
const loadingApiKeys = ref(false)
const passwordCopied = ref(false)
let abortController: AbortController | null = null
// Action Menu State
const activeMenuId = ref<number | null>(null)
const menuPosition = ref<{ top: number; left: number } | null>(null)
const actionButtonRefs = ref<Map<number, HTMLElement>>(new Map())
const setActionButtonRef = (userId: number, el: Element | ComponentPublicInstance | null) => {
if (el instanceof HTMLElement) {
actionButtonRefs.value.set(userId, el)
} else {
actionButtonRefs.value.delete(userId)
}
}
const openActionMenu = (user: User) => {
if (activeMenuId.value === user.id) {
closeActionMenu()
} else {
const buttonEl = actionButtonRefs.value.get(user.id)
if (buttonEl) {
const rect = buttonEl.getBoundingClientRect()
const menuWidth = 192
const menuHeight = 240
const padding = 8
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const left = Math.min(
Math.max(rect.right - menuWidth, padding),
Math.max(viewportWidth - menuWidth - padding, padding)
)
let top = rect.bottom + 4
if (top + menuHeight > viewportHeight - padding) {
top = Math.max(rect.top - menuHeight - 4, padding)
}
// Position menu near the trigger, clamped to viewport
menuPosition.value = {
top,
left
}
}
activeMenuId.value = user.id
}
}
const closeActionMenu = () => {
activeMenuId.value = null
menuPosition.value = null
}
// Close menu when clicking outside
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (!target.closest('.action-menu-trigger') && !target.closest('.action-menu-content')) {
closeActionMenu()
}
}
// Allowed groups modal state
const showAllowedGroupsModal = ref(false)
......@@ -1331,13 +1405,25 @@ const copyEditPassword = async () => {
}
const loadUsers = async () => {
abortController?.abort()
const currentAbortController = new AbortController()
abortController = currentAbortController
const { signal } = currentAbortController
loading.value = true
try {
const response = await adminAPI.users.list(pagination.page, pagination.page_size, {
role: filters.role as any,
status: filters.status as any,
search: searchQuery.value || undefined
})
const response = await adminAPI.users.list(
pagination.page,
pagination.page_size,
{
role: filters.role as any,
status: filters.status as any,
search: searchQuery.value || undefined
},
{ signal }
)
if (signal.aborted) {
return
}
users.value = response.items
pagination.total = response.total
pagination.pages = response.pages
......@@ -1347,16 +1433,28 @@ const loadUsers = async () => {
const userIds = response.items.map((u) => u.id)
try {
const usageResponse = await adminAPI.dashboard.getBatchUsersUsage(userIds)
if (signal.aborted) {
return
}
usageStats.value = usageResponse.stats
} catch (e) {
if (signal.aborted) {
return
}
console.error('Failed to load usage stats:', e)
}
}
} catch (error) {
const errorInfo = error as { name?: string; code?: string }
if (errorInfo?.name === 'AbortError' || errorInfo?.name === 'CanceledError' || errorInfo?.code === 'ERR_CANCELED') {
return
}
appStore.showError(t('admin.users.failedToLoad'))
console.error('Error loading users:', error)
} finally {
loading.value = false
if (abortController === currentAbortController) {
loading.value = false
}
}
}
......@@ -1374,6 +1472,12 @@ const handlePageChange = (page: number) => {
loadUsers()
}
const handlePageSizeChange = (pageSize: number) => {
pagination.page_size = pageSize
pagination.page = 1
loadUsers()
}
const closeCreateModal = () => {
showCreateModal.value = false
createForm.email = ''
......@@ -1620,5 +1724,10 @@ const handleBalanceSubmit = async () => {
onMounted(() => {
loadUsers()
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>
......@@ -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))
......
......@@ -322,7 +322,13 @@
<!-- Charts Grid -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- Model Distribution Chart -->
<div class="card p-4">
<div class="card relative overflow-hidden p-4">
<div
v-if="loadingCharts"
class="absolute inset-0 z-10 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-dark-800/50"
>
<LoadingSpinner size="md" />
</div>
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
{{ t('dashboard.modelDistribution') }}
</h3>
......@@ -330,6 +336,7 @@
<div class="h-48 w-48">
<Doughnut
v-if="modelChartData"
ref="modelChartRef"
:data="modelChartData"
:options="doughnutOptions"
/>
......@@ -383,12 +390,23 @@
</div>
<!-- Token Usage Trend Chart -->
<div class="card p-4">
<div class="card relative overflow-hidden p-4">
<div
v-if="loadingCharts"
class="absolute inset-0 z-10 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-dark-800/50"
>
<LoadingSpinner size="md" />
</div>
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
{{ t('dashboard.tokenUsageTrend') }}
</h3>
<div class="h-48">
<Line v-if="trendChartData" :data="trendChartData" :options="lineOptions" />
<Line
v-if="trendChartData"
ref="trendChartRef"
:data="trendChartData"
:options="lineOptions"
/>
<div
v-else
class="flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400"
......@@ -645,10 +663,11 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { useSubscriptionStore } from '@/stores/subscriptions'
import { formatDateTime } from '@/utils/format'
const { t } = useI18n()
......@@ -689,15 +708,21 @@ ChartJS.register(
const router = useRouter()
const authStore = useAuthStore()
const subscriptionStore = useSubscriptionStore()
const user = computed(() => authStore.user)
const stats = ref<UserDashboardStats | null>(null)
const loading = ref(false)
const loadingUsage = ref(false)
const loadingCharts = ref(false)
type ChartComponentRef = { chart?: ChartJS }
// Chart data
const trendData = ref<TrendDataPoint[]>([])
const modelStats = ref<ModelStat[]>([])
const modelChartRef = ref<ChartComponentRef | null>(null)
const trendChartRef = ref<ChartComponentRef | null>(null)
// Recent usage
const recentUsage = ref<UsageLog[]>([])
......@@ -964,6 +989,7 @@ const loadDashboardStats = async () => {
}
const loadChartData = async () => {
loadingCharts.value = true
try {
const params = {
start_date: startDate.value,
......@@ -981,14 +1007,16 @@ const loadChartData = async () => {
modelStats.value = modelResponse.models || []
} catch (error) {
console.error('Error loading chart data:', error)
} finally {
loadingCharts.value = false
}
}
const loadRecentUsage = async () => {
loadingUsage.value = true
try {
const endDate = new Date().toISOString()
const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString()
const endDate = new Date().toISOString().split('T')[0]
const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
const usageResponse = await usageAPI.getByDateRange(startDate, endDate)
recentUsage.value = usageResponse.items.slice(0, 5)
} catch (error) {
......@@ -998,16 +1026,30 @@ const loadRecentUsage = async () => {
}
}
onMounted(() => {
loadDashboardStats()
onMounted(async () => {
// Load critical data first
await loadDashboardStats()
// Force refresh subscription status when entering dashboard (bypass cache)
subscriptionStore.fetchActiveSubscriptions(true).catch((error) => {
console.error('Failed to refresh subscription status:', error)
})
// Initialize date range (synchronous)
initializeDateRange()
loadChartData()
loadRecentUsage()
// Load chart data and recent usage in parallel (non-critical)
Promise.all([loadChartData(), loadRecentUsage()]).catch((error) => {
console.error('Error loading secondary data:', error)
})
})
// Watch for dark mode changes
watch(isDarkMode, () => {
// Force chart re-render on theme change
nextTick(() => {
modelChartRef.value?.chart?.update()
trendChartRef.value?.chart?.update()
})
})
</script>
......
......@@ -292,17 +292,19 @@
:total="pagination.total"
:page-size="pagination.page_size"
@update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/>
</template>
</TablePageLayout>
<!-- Create/Edit Modal -->
<Modal
<BaseDialog
:show="showCreateModal || showEditModal"
:title="showEditModal ? t('keys.editKey') : t('keys.createKey')"
width="narrow"
@close="closeModals"
>
<form @submit.prevent="handleSubmit" class="space-y-5">
<form id="key-form" @submit.prevent="handleSubmit" class="space-y-5">
<div>
<label class="input-label">{{ t('keys.nameLabel') }}</label>
<input
......@@ -383,12 +385,13 @@
:placeholder="t('keys.selectStatus')"
/>
</div>
<div class="flex justify-end gap-3 pt-4">
</form>
<template #footer>
<div class="flex justify-end gap-3">
<button @click="closeModals" type="button" class="btn btn-secondary">
{{ t('common.cancel') }}
</button>
<button type="submit" :disabled="submitting" class="btn btn-primary">
<button form="key-form" type="submit" :disabled="submitting" class="btn btn-primary">
<svg
v-if="submitting"
class="-ml-1 mr-2 h-4 w-4 animate-spin"
......@@ -418,8 +421,8 @@
}}
</button>
</div>
</form>
</Modal>
</template>
</BaseDialog>
<!-- Delete Confirmation Dialog -->
<ConfirmDialog
......@@ -501,7 +504,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'
......@@ -557,6 +560,7 @@ const publicSettings = ref<PublicSettings | null>(null)
const dropdownRef = ref<HTMLElement | null>(null)
const dropdownPosition = ref<{ top: number; left: number } | null>(null)
const groupButtonRefs = ref<Map<number, HTMLElement>>(new Map())
let abortController: AbortController | null = null
// Get the currently selected key for group change
const selectedKeyForGroup = computed(() => {
......@@ -623,14 +627,27 @@ const copyToClipboard = async (text: string, keyId: number) => {
copiedKeyId.value = keyId
setTimeout(() => {
copiedKeyId.value = null
}, 2000)
}, 800)
}
}
const isAbortError = (error: unknown) => {
if (!error || typeof error !== 'object') return false
const { name, code } = error as { name?: string; code?: string }
return name === 'AbortError' || code === 'ERR_CANCELED'
}
const loadApiKeys = async () => {
abortController?.abort()
const controller = new AbortController()
abortController = controller
const { signal } = controller
loading.value = true
try {
const response = await keysAPI.list(pagination.value.page, pagination.value.page_size)
const response = await keysAPI.list(pagination.value.page, pagination.value.page_size, {
signal
})
if (signal.aborted) return
apiKeys.value = response.items
pagination.value.total = response.total
pagination.value.pages = response.pages
......@@ -639,16 +656,24 @@ const loadApiKeys = async () => {
if (response.items.length > 0) {
const keyIds = response.items.map((k) => k.id)
try {
const usageResponse = await usageAPI.getDashboardApiKeysUsage(keyIds)
const usageResponse = await usageAPI.getDashboardApiKeysUsage(keyIds, { signal })
if (signal.aborted) return
usageStats.value = usageResponse.stats
} catch (e) {
console.error('Failed to load usage stats:', e)
if (!isAbortError(e)) {
console.error('Failed to load usage stats:', e)
}
}
}
} catch (error) {
if (isAbortError(error)) {
return
}
appStore.showError(t('keys.failedToLoad'))
} finally {
loading.value = false
if (abortController === controller) {
loading.value = false
}
}
}
......@@ -683,6 +708,12 @@ const handlePageChange = (page: number) => {
loadApiKeys()
}
const handlePageSizeChange = (pageSize: number) => {
pagination.value.page_size = pageSize
pagination.value.page = 1
loadApiKeys()
}
const editKey = (key: ApiKey) => {
selectedKey.value = key
formData.value = {
......
......@@ -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 = ''
......
......@@ -164,8 +164,28 @@
<button @click="resetFilters" class="btn btn-secondary">
{{ t('common.reset') }}
</button>
<button @click="exportToCSV" class="btn btn-primary">
{{ t('usage.exportCsv') }}
<button @click="exportToCSV" :disabled="exporting" class="btn btn-primary">
<svg
v-if="exporting"
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>
{{ exporting ? t('usage.exporting') : t('usage.exportCsv') }}
</button>
</div>
</div>
......@@ -366,6 +386,7 @@
:total="pagination.total"
:page-size="pagination.page_size"
@update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/>
</template>
</TablePageLayout>
......@@ -412,7 +433,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, reactive, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { usageAPI, keysAPI } from '@/api'
......@@ -430,6 +451,8 @@ import { formatDateTime } from '@/utils/format'
const { t } = useI18n()
const appStore = useAppStore()
let abortController: AbortController | null = null
// Tooltip state
const tooltipVisible = ref(false)
const tooltipPosition = ref({ x: 0, y: 0 })
......@@ -453,6 +476,7 @@ const columns = computed<Column[]>(() => [
const usageLogs = ref<UsageLog[]>([])
const apiKeys = ref<ApiKey[]>([])
const loading = ref(false)
const exporting = ref(false)
const apiKeyOptions = computed(() => {
return [
......@@ -498,7 +522,7 @@ const onDateRangeChange = (range: {
applyFilters()
}
const pagination = ref({
const pagination = reactive({
page: 1,
page_size: 20,
total: 0,
......@@ -532,22 +556,40 @@ const formatCacheTokens = (value: number): string => {
}
const loadUsageLogs = async () => {
if (abortController) {
abortController.abort()
}
const currentAbortController = new AbortController()
abortController = currentAbortController
const { signal } = currentAbortController
loading.value = true
try {
const params: UsageQueryParams = {
page: pagination.value.page,
page_size: pagination.value.page_size,
page: pagination.page,
page_size: pagination.page_size,
...filters.value
}
const response = await usageAPI.query(params)
const response = await usageAPI.query(params, { signal })
if (signal.aborted) {
return
}
usageLogs.value = response.items
pagination.value.total = response.total
pagination.value.pages = response.pages
pagination.total = response.total
pagination.pages = response.pages
} catch (error) {
if (signal.aborted) {
return
}
const abortError = error as { name?: string; code?: string }
if (abortError?.name === 'AbortError' || abortError?.code === 'ERR_CANCELED') {
return
}
appStore.showError(t('usage.failedToLoad'))
} finally {
loading.value = false
if (abortController === currentAbortController) {
loading.value = false
}
}
}
......@@ -575,7 +617,7 @@ const loadUsageStats = async () => {
}
const applyFilters = () => {
pagination.value.page = 1
pagination.page = 1
loadUsageLogs()
loadUsageStats()
}
......@@ -588,60 +630,128 @@ const resetFilters = () => {
}
// Reset date range to default (last 7 days)
initializeDateRange()
pagination.value.page = 1
pagination.page = 1
loadUsageLogs()
loadUsageStats()
}
const handlePageChange = (page: number) => {
pagination.value.page = page
pagination.page = page
loadUsageLogs()
}
const exportToCSV = () => {
if (usageLogs.value.length === 0) {
const handlePageSizeChange = (pageSize: number) => {
pagination.page_size = pageSize
pagination.page = 1
loadUsageLogs()
}
/**
* Escape CSV value to prevent injection and handle special characters
*/
const escapeCSVValue = (value: unknown): string => {
if (value == null) return ''
const str = String(value)
const escaped = str.replace(/"/g, '""')
// Prevent formula injection by prefixing dangerous characters with single quote
if (/^[=+\-@\t\r]/.test(str)) {
return `"\'${escaped}"`
}
// Escape values containing comma, quote, or newline
if (/[,"\n\r]/.test(str)) {
return `"${escaped}"`
}
return str
}
const exportToCSV = async () => {
if (pagination.total === 0) {
appStore.showWarning(t('usage.noDataToExport'))
return
}
const headers = [
'Model',
'Type',
'Input Tokens',
'Output Tokens',
'Cache Read Tokens',
'Cache Write Tokens',
'Total Cost',
'Billing Type',
'First Token (ms)',
'Duration (ms)',
'Time'
]
const rows = usageLogs.value.map((log) => [
log.model,
log.stream ? 'Stream' : 'Sync',
log.input_tokens,
log.output_tokens,
log.cache_read_tokens,
log.cache_creation_tokens,
log.total_cost.toFixed(6),
log.billing_type === 1 ? 'Subscription' : 'Balance',
log.first_token_ms ?? '',
log.duration_ms,
log.created_at
])
const csvContent = [headers.join(','), ...rows.map((row) => row.join(','))].join('\n')
const blob = new Blob([csvContent], { type: 'text/csv' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `usage_${new Date().toISOString().split('T')[0]}.csv`
link.click()
window.URL.revokeObjectURL(url)
appStore.showSuccess(t('usage.exportSuccess'))
exporting.value = true
appStore.showInfo(t('usage.preparingExport'))
try {
const allLogs: UsageLog[] = []
const pageSize = 100 // Use a larger page size for export to reduce requests
const totalRequests = Math.ceil(pagination.total / pageSize)
for (let page = 1; page <= totalRequests; page++) {
const params: UsageQueryParams = {
page: page,
page_size: pageSize,
...filters.value
}
const response = await usageAPI.query(params)
allLogs.push(...response.items)
}
if (allLogs.length === 0) {
appStore.showWarning(t('usage.noDataToExport'))
return
}
const headers = [
'Time',
'API Key Name',
'Model',
'Type',
'Input Tokens',
'Output Tokens',
'Cache Read Tokens',
'Cache Creation Tokens',
'Rate Multiplier',
'Billed Cost',
'Original Cost',
'Billing Type',
'First Token (ms)',
'Duration (ms)'
]
const rows = allLogs.map((log) =>
[
log.created_at,
log.api_key?.name || '',
log.model,
log.stream ? 'Stream' : 'Sync',
log.input_tokens,
log.output_tokens,
log.cache_read_tokens,
log.cache_creation_tokens,
log.rate_multiplier,
log.actual_cost.toFixed(8),
log.total_cost.toFixed(8),
log.billing_type === 1 ? 'Subscription' : 'Balance',
log.first_token_ms ?? '',
log.duration_ms
].map(escapeCSVValue)
)
const csvContent = [
headers.map(escapeCSVValue).join(','),
...rows.map((row) => row.join(','))
].join('\n')
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `usage_${filters.value.start_date}_to_${filters.value.end_date}.csv`
link.click()
window.URL.revokeObjectURL(url)
appStore.showSuccess(t('usage.exportSuccess'))
} catch (error) {
appStore.showError(t('usage.exportFailed'))
console.error('CSV Export failed:', error)
} finally {
exporting.value = false
}
}
// Tooltip functions
......
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