Merge branch 'main' into feature/antigravity_auth

This commit is contained in:
song
2025-12-28 18:46:18 +08:00
49 changed files with 1754 additions and 707 deletions

View File

@@ -18,8 +18,10 @@ func DefaultModels() []Model {
methods := []string{"generateContent", "streamGenerateContent"} methods := []string{"generateContent", "streamGenerateContent"}
return []Model{ return []Model{
{Name: "models/gemini-3-pro-preview", SupportedGenerationMethods: methods}, {Name: "models/gemini-3-pro-preview", SupportedGenerationMethods: methods},
{Name: "models/gemini-3-flash-preview", SupportedGenerationMethods: methods},
{Name: "models/gemini-2.5-pro", SupportedGenerationMethods: methods},
{Name: "models/gemini-2.5-flash", SupportedGenerationMethods: methods},
{Name: "models/gemini-2.0-flash", SupportedGenerationMethods: methods}, {Name: "models/gemini-2.0-flash", SupportedGenerationMethods: methods},
{Name: "models/gemini-2.0-flash-lite", SupportedGenerationMethods: methods},
{Name: "models/gemini-1.5-pro", SupportedGenerationMethods: methods}, {Name: "models/gemini-1.5-pro", SupportedGenerationMethods: methods},
{Name: "models/gemini-1.5-flash", SupportedGenerationMethods: methods}, {Name: "models/gemini-1.5-flash", SupportedGenerationMethods: methods},
{Name: "models/gemini-1.5-flash-8b", SupportedGenerationMethods: methods}, {Name: "models/gemini-1.5-flash-8b", SupportedGenerationMethods: methods},

View File

@@ -11,11 +11,11 @@ type Model struct {
// DefaultModels is the curated Gemini model list used by the admin UI "test account" flow. // DefaultModels is the curated Gemini model list used by the admin UI "test account" flow.
var DefaultModels = []Model{ var DefaultModels = []Model{
{ID: "gemini-3-pro", Type: "model", DisplayName: "Gemini 3 Pro", CreatedAt: ""}, {ID: "gemini-3-pro-preview", Type: "model", DisplayName: "Gemini 3 Pro Preview", CreatedAt: ""},
{ID: "gemini-3-flash", Type: "model", DisplayName: "Gemini 3 Flash", CreatedAt: ""}, {ID: "gemini-3-flash-preview", Type: "model", DisplayName: "Gemini 3 Flash Preview", CreatedAt: ""},
{ID: "gemini-2.5-pro", Type: "model", DisplayName: "Gemini 2.5 Pro", CreatedAt: ""}, {ID: "gemini-2.5-pro", Type: "model", DisplayName: "Gemini 2.5 Pro", CreatedAt: ""},
{ID: "gemini-2.5-flash", Type: "model", DisplayName: "Gemini 2.5 Flash", CreatedAt: ""}, {ID: "gemini-2.5-flash", Type: "model", DisplayName: "Gemini 2.5 Flash", CreatedAt: ""},
} }
// DefaultTestModel is the default model to preselect in test flows. // DefaultTestModel is the default model to preselect in test flows.
const DefaultTestModel = "gemini-2.5-pro" const DefaultTestModel = "gemini-3-pro-preview"

View File

@@ -8,7 +8,6 @@ import (
"github.com/Wei-Shaw/sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm"
) )
// ApiKeyAuthGoogle is a Google-style error wrapper for API key auth. // ApiKeyAuthGoogle is a Google-style error wrapper for API key auth.
@@ -30,7 +29,7 @@ func ApiKeyAuthWithSubscriptionGoogle(apiKeyService *service.ApiKeyService, subs
apiKey, err := apiKeyService.GetByKey(c.Request.Context(), apiKeyString) apiKey, err := apiKeyService.GetByKey(c.Request.Context(), apiKeyString)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, service.ErrApiKeyNotFound) {
abortWithGoogleError(c, 401, "Invalid API key") abortWithGoogleError(c, 401, "Invalid API key")
return return
} }

View File

@@ -2,12 +2,14 @@
import { RouterView, useRouter, useRoute } from 'vue-router' import { RouterView, useRouter, useRoute } from 'vue-router'
import { onMounted, watch } from 'vue' import { onMounted, watch } from 'vue'
import Toast from '@/components/common/Toast.vue' import Toast from '@/components/common/Toast.vue'
import { useAppStore } from '@/stores' import { useAppStore, useAuthStore, useSubscriptionStore } from '@/stores'
import { getSetupStatus } from '@/api/setup' import { getSetupStatus } from '@/api/setup'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const appStore = useAppStore() const appStore = useAppStore()
const authStore = useAuthStore()
const subscriptionStore = useSubscriptionStore()
/** /**
* Update favicon dynamically * Update favicon dynamically
@@ -46,6 +48,24 @@ watch(
{ immediate: true } { immediate: true }
) )
// Watch for authentication state and manage subscription data
watch(
() => authStore.isAuthenticated,
(isAuthenticated) => {
if (isAuthenticated) {
// User logged in: preload subscriptions and start polling
subscriptionStore.fetchActiveSubscriptions().catch((error) => {
console.error('Failed to preload subscriptions:', error)
})
subscriptionStore.startPolling()
} else {
// User logged out: clear data and stop polling
subscriptionStore.clear()
}
},
{ immediate: true }
)
onMounted(async () => { onMounted(async () => {
// Check if setup is needed // Check if setup is needed
try { try {

View File

@@ -30,6 +30,9 @@ export async function list(
type?: string type?: string
status?: string status?: string
search?: string search?: string
},
options?: {
signal?: AbortSignal
} }
): Promise<PaginatedResponse<Account>> { ): Promise<PaginatedResponse<Account>> {
const { data } = await apiClient.get<PaginatedResponse<Account>>('/admin/accounts', { const { data } = await apiClient.get<PaginatedResponse<Account>>('/admin/accounts', {
@@ -37,7 +40,8 @@ export async function list(
page, page,
page_size: pageSize, page_size: pageSize,
...filters ...filters
} },
signal: options?.signal
}) })
return data return data
} }

View File

@@ -26,6 +26,9 @@ export async function list(
platform?: GroupPlatform platform?: GroupPlatform
status?: 'active' | 'inactive' status?: 'active' | 'inactive'
is_exclusive?: boolean is_exclusive?: boolean
},
options?: {
signal?: AbortSignal
} }
): Promise<PaginatedResponse<Group>> { ): Promise<PaginatedResponse<Group>> {
const { data } = await apiClient.get<PaginatedResponse<Group>>('/admin/groups', { const { data } = await apiClient.get<PaginatedResponse<Group>>('/admin/groups', {
@@ -33,7 +36,8 @@ export async function list(
page, page,
page_size: pageSize, page_size: pageSize,
...filters ...filters
} },
signal: options?.signal
}) })
return data return data
} }

View File

@@ -20,6 +20,9 @@ export async function list(
protocol?: string protocol?: string
status?: 'active' | 'inactive' status?: 'active' | 'inactive'
search?: string search?: string
},
options?: {
signal?: AbortSignal
} }
): Promise<PaginatedResponse<Proxy>> { ): Promise<PaginatedResponse<Proxy>> {
const { data } = await apiClient.get<PaginatedResponse<Proxy>>('/admin/proxies', { const { data } = await apiClient.get<PaginatedResponse<Proxy>>('/admin/proxies', {
@@ -27,7 +30,8 @@ export async function list(
page, page,
page_size: pageSize, page_size: pageSize,
...filters ...filters
} },
signal: options?.signal
}) })
return data return data
} }

View File

@@ -25,6 +25,9 @@ export async function list(
type?: RedeemCodeType type?: RedeemCodeType
status?: 'active' | 'used' | 'expired' | 'unused' status?: 'active' | 'used' | 'expired' | 'unused'
search?: string search?: string
},
options?: {
signal?: AbortSignal
} }
): Promise<PaginatedResponse<RedeemCode>> { ): Promise<PaginatedResponse<RedeemCode>> {
const { data } = await apiClient.get<PaginatedResponse<RedeemCode>>('/admin/redeem-codes', { const { data } = await apiClient.get<PaginatedResponse<RedeemCode>>('/admin/redeem-codes', {
@@ -32,7 +35,8 @@ export async function list(
page, page,
page_size: pageSize, page_size: pageSize,
...filters ...filters
} },
signal: options?.signal
}) })
return data return data
} }

View File

@@ -27,6 +27,9 @@ export async function list(
status?: 'active' | 'expired' | 'revoked' status?: 'active' | 'expired' | 'revoked'
user_id?: number user_id?: number
group_id?: number group_id?: number
},
options?: {
signal?: AbortSignal
} }
): Promise<PaginatedResponse<UserSubscription>> { ): Promise<PaginatedResponse<UserSubscription>> {
const { data } = await apiClient.get<PaginatedResponse<UserSubscription>>( const { data } = await apiClient.get<PaginatedResponse<UserSubscription>>(
@@ -36,7 +39,8 @@ export async function list(
page, page,
page_size: pageSize, page_size: pageSize,
...filters ...filters
} },
signal: options?.signal
} }
) )
return data return data

View File

@@ -41,9 +41,13 @@ export interface AdminUsageQueryParams extends UsageQueryParams {
* @param params - Query parameters for filtering and pagination * @param params - Query parameters for filtering and pagination
* @returns Paginated list of usage logs * @returns Paginated list of usage logs
*/ */
export async function list(params: AdminUsageQueryParams): Promise<PaginatedResponse<UsageLog>> { export async function list(
params: AdminUsageQueryParams,
options?: { signal?: AbortSignal }
): Promise<PaginatedResponse<UsageLog>> {
const { data } = await apiClient.get<PaginatedResponse<UsageLog>>('/admin/usage', { const { data } = await apiClient.get<PaginatedResponse<UsageLog>>('/admin/usage', {
params params,
signal: options?.signal
}) })
return data return data
} }

View File

@@ -11,6 +11,7 @@ import type { User, UpdateUserRequest, PaginatedResponse } from '@/types'
* @param page - Page number (default: 1) * @param page - Page number (default: 1)
* @param pageSize - Items per page (default: 20) * @param pageSize - Items per page (default: 20)
* @param filters - Optional filters (status, role, search) * @param filters - Optional filters (status, role, search)
* @param options - Optional request options (signal)
* @returns Paginated list of users * @returns Paginated list of users
*/ */
export async function list( export async function list(
@@ -20,6 +21,9 @@ export async function list(
status?: 'active' | 'disabled' status?: 'active' | 'disabled'
role?: 'admin' | 'user' role?: 'admin' | 'user'
search?: string search?: string
},
options?: {
signal?: AbortSignal
} }
): Promise<PaginatedResponse<User>> { ): Promise<PaginatedResponse<User>> {
const { data } = await apiClient.get<PaginatedResponse<User>>('/admin/users', { const { data } = await apiClient.get<PaginatedResponse<User>>('/admin/users', {
@@ -27,7 +31,8 @@ export async function list(
page, page,
page_size: pageSize, page_size: pageSize,
...filters ...filters
} },
signal: options?.signal
}) })
return data return data
} }

View File

@@ -10,14 +10,19 @@ import type { ApiKey, CreateApiKeyRequest, UpdateApiKeyRequest, PaginatedRespons
* List all API keys for current user * List all API keys for current user
* @param page - Page number (default: 1) * @param page - Page number (default: 1)
* @param pageSize - Items per page (default: 10) * @param pageSize - Items per page (default: 10)
* @param options - Optional request options
* @returns Paginated list of API keys * @returns Paginated list of API keys
*/ */
export async function list( export async function list(
page: number = 1, page: number = 1,
pageSize: number = 10 pageSize: number = 10,
options?: {
signal?: AbortSignal
}
): Promise<PaginatedResponse<ApiKey>> { ): Promise<PaginatedResponse<ApiKey>> {
const { data } = await apiClient.get<PaginatedResponse<ApiKey>>('/keys', { const { data } = await apiClient.get<PaginatedResponse<ApiKey>>('/keys', {
params: { page, page_size: pageSize } params: { page, page_size: pageSize },
signal: options?.signal
}) })
return data return data
} }

View File

@@ -90,8 +90,12 @@ export async function list(
* @param params - Query parameters for filtering and pagination * @param params - Query parameters for filtering and pagination
* @returns Paginated list of usage logs * @returns Paginated list of usage logs
*/ */
export async function query(params: UsageQueryParams): Promise<PaginatedResponse<UsageLog>> { export async function query(
params: UsageQueryParams,
config: { signal?: AbortSignal } = {}
): Promise<PaginatedResponse<UsageLog>> {
const { data } = await apiClient.get<PaginatedResponse<UsageLog>>('/usage', { const { data } = await apiClient.get<PaginatedResponse<UsageLog>>('/usage', {
...config,
params params
}) })
return data return data
@@ -148,8 +152,8 @@ export async function getStatsByDateRange(
/** /**
* Get usage by date range * Get usage by date range
* @param startDate - Start date (ISO format) * @param startDate - Start date (YYYY-MM-DD format)
* @param endDate - End date (ISO format) * @param endDate - End date (YYYY-MM-DD format)
* @param apiKeyId - Optional API key ID filter * @param apiKeyId - Optional API key ID filter
* @returns Usage logs within date range * @returns Usage logs within date range
*/ */
@@ -232,15 +236,22 @@ export interface BatchApiKeysUsageResponse {
/** /**
* Get batch usage stats for user's own API keys * Get batch usage stats for user's own API keys
* @param apiKeyIds - Array of API key IDs * @param apiKeyIds - Array of API key IDs
* @param options - Optional request options
* @returns Usage stats map keyed by API key ID * @returns Usage stats map keyed by API key ID
*/ */
export async function getDashboardApiKeysUsage( export async function getDashboardApiKeysUsage(
apiKeyIds: number[] apiKeyIds: number[],
options?: {
signal?: AbortSignal
}
): Promise<BatchApiKeysUsageResponse> { ): Promise<BatchApiKeysUsageResponse> {
const { data } = await apiClient.post<BatchApiKeysUsageResponse>( const { data } = await apiClient.post<BatchApiKeysUsageResponse>(
'/usage/dashboard/api-keys-usage', '/usage/dashboard/api-keys-usage',
{ {
api_key_ids: apiKeyIds api_key_ids: apiKeyIds
},
{
signal: options?.signal
} }
) )
return data return data

View File

@@ -1,5 +1,10 @@
<template> <template>
<Modal :show="show" :title="t('admin.accounts.usageStatistics')" size="2xl" @close="handleClose"> <BaseDialog
:show="show"
:title="t('admin.accounts.usageStatistics')"
width="extra-wide"
@close="handleClose"
>
<div class="space-y-6"> <div class="space-y-6">
<!-- Account Info Header --> <!-- Account Info Header -->
<div <div
@@ -521,7 +526,7 @@
</button> </button>
</div> </div>
</template> </template>
</Modal> </BaseDialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -539,7 +544,7 @@ import {
Filler Filler
} from 'chart.js' } from 'chart.js'
import { Line } from 'vue-chartjs' import { Line } from 'vue-chartjs'
import Modal from '@/components/common/Modal.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue' import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue' import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'

View File

@@ -1,8 +1,8 @@
<template> <template>
<Modal <BaseDialog
:show="show" :show="show"
:title="t('admin.accounts.testAccountConnection')" :title="t('admin.accounts.testAccountConnection')"
size="md" width="normal"
@close="handleClose" @close="handleClose"
> >
<div class="space-y-4"> <div class="space-y-4">
@@ -273,13 +273,13 @@
</button> </button>
</div> </div>
</template> </template>
</Modal> </BaseDialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, nextTick } from 'vue' import { ref, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import Modal from '@/components/common/Modal.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import { useClipboard } from '@/composables/useClipboard' import { useClipboard } from '@/composables/useClipboard'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import type { Account, ClaudeModel } from '@/types' import type { Account, ClaudeModel } from '@/types'

View File

@@ -1,6 +1,11 @@
<template> <template>
<Modal :show="show" :title="t('admin.accounts.bulkEdit.title')" size="lg" @close="handleClose"> <BaseDialog
<form class="space-y-5" @submit.prevent="handleSubmit"> :show="show"
:title="t('admin.accounts.bulkEdit.title')"
width="wide"
@close="handleClose"
>
<form id="bulk-edit-account-form" class="space-y-5" @submit.prevent="handleSubmit">
<!-- Info --> <!-- Info -->
<div class="rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20"> <div class="rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20">
<p class="text-sm text-blue-700 dark:text-blue-400"> <p class="text-sm text-blue-700 dark:text-blue-400">
@@ -19,20 +24,30 @@
<!-- Base URL (API Key only) --> <!-- Base URL (API Key only) -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<label class="input-label mb-0">{{ t('admin.accounts.baseUrl') }}</label> <label
id="bulk-edit-base-url-label"
class="input-label mb-0"
for="bulk-edit-base-url-enabled"
>
{{ t('admin.accounts.baseUrl') }}
</label>
<input <input
v-model="enableBaseUrl" v-model="enableBaseUrl"
id="bulk-edit-base-url-enabled"
type="checkbox" type="checkbox"
aria-controls="bulk-edit-base-url"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/> />
</div> </div>
<input <input
v-model="baseUrl" v-model="baseUrl"
id="bulk-edit-base-url"
type="text" type="text"
:disabled="!enableBaseUrl" :disabled="!enableBaseUrl"
class="input" class="input"
:class="!enableBaseUrl && 'cursor-not-allowed opacity-50'" :class="!enableBaseUrl && 'cursor-not-allowed opacity-50'"
:placeholder="t('admin.accounts.bulkEdit.baseUrlPlaceholder')" :placeholder="t('admin.accounts.bulkEdit.baseUrlPlaceholder')"
aria-labelledby="bulk-edit-base-url-label"
/> />
<p class="input-hint"> <p class="input-hint">
{{ t('admin.accounts.bulkEdit.baseUrlNotice') }} {{ t('admin.accounts.bulkEdit.baseUrlNotice') }}
@@ -42,15 +57,28 @@
<!-- Model restriction --> <!-- Model restriction -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<label class="input-label mb-0">{{ t('admin.accounts.modelRestriction') }}</label> <label
id="bulk-edit-model-restriction-label"
class="input-label mb-0"
for="bulk-edit-model-restriction-enabled"
>
{{ t('admin.accounts.modelRestriction') }}
</label>
<input <input
v-model="enableModelRestriction" v-model="enableModelRestriction"
id="bulk-edit-model-restriction-enabled"
type="checkbox" type="checkbox"
aria-controls="bulk-edit-model-restriction-body"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/> />
</div> </div>
<div :class="!enableModelRestriction && 'pointer-events-none opacity-50'"> <div
id="bulk-edit-model-restriction-body"
:class="!enableModelRestriction && 'pointer-events-none opacity-50'"
role="group"
aria-labelledby="bulk-edit-model-restriction-label"
>
<!-- Mode Toggle --> <!-- Mode Toggle -->
<div class="mb-4 flex gap-2"> <div class="mb-4 flex gap-2">
<button <button
@@ -267,19 +295,27 @@
<div class="border-t border-gray-200 pt-4 dark:border-dark-600"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<div> <div>
<label class="input-label mb-0">{{ t('admin.accounts.customErrorCodes') }}</label> <label
id="bulk-edit-custom-error-codes-label"
class="input-label mb-0"
for="bulk-edit-custom-error-codes-enabled"
>
{{ t('admin.accounts.customErrorCodes') }}
</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.customErrorCodesHint') }} {{ t('admin.accounts.customErrorCodesHint') }}
</p> </p>
</div> </div>
<input <input
v-model="enableCustomErrorCodes" v-model="enableCustomErrorCodes"
id="bulk-edit-custom-error-codes-enabled"
type="checkbox" type="checkbox"
aria-controls="bulk-edit-custom-error-codes-body"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/> />
</div> </div>
<div v-if="enableCustomErrorCodes" class="space-y-3"> <div v-if="enableCustomErrorCodes" id="bulk-edit-custom-error-codes-body" class="space-y-3">
<div class="rounded-lg bg-amber-50 p-3 dark:bg-amber-900/20"> <div class="rounded-lg bg-amber-50 p-3 dark:bg-amber-900/20">
<p class="text-xs text-amber-700 dark:text-amber-400"> <p class="text-xs text-amber-700 dark:text-amber-400">
<svg <svg
@@ -321,11 +357,13 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input <input
v-model="customErrorCodeInput" v-model="customErrorCodeInput"
id="bulk-edit-custom-error-code-input"
type="number" type="number"
min="100" min="100"
max="599" max="599"
class="input flex-1" class="input flex-1"
:placeholder="t('admin.accounts.enterErrorCode')" :placeholder="t('admin.accounts.enterErrorCode')"
aria-labelledby="bulk-edit-custom-error-codes-label"
@keyup.enter="addCustomErrorCode" @keyup.enter="addCustomErrorCode"
/> />
<button type="button" class="btn btn-secondary px-3" @click="addCustomErrorCode"> <button type="button" class="btn btn-secondary px-3" @click="addCustomErrorCode">
@@ -374,20 +412,26 @@
<div class="border-t border-gray-200 pt-4 dark:border-dark-600"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex-1 pr-4"> <div class="flex-1 pr-4">
<label class="input-label mb-0">{{ <label
t('admin.accounts.interceptWarmupRequests') id="bulk-edit-intercept-warmup-label"
}}</label> class="input-label mb-0"
for="bulk-edit-intercept-warmup-enabled"
>
{{ t('admin.accounts.interceptWarmupRequests') }}
</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.interceptWarmupRequestsDesc') }} {{ t('admin.accounts.interceptWarmupRequestsDesc') }}
</p> </p>
</div> </div>
<input <input
v-model="enableInterceptWarmup" v-model="enableInterceptWarmup"
id="bulk-edit-intercept-warmup-enabled"
type="checkbox" type="checkbox"
aria-controls="bulk-edit-intercept-warmup-body"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/> />
</div> </div>
<div v-if="enableInterceptWarmup" class="mt-3"> <div v-if="enableInterceptWarmup" id="bulk-edit-intercept-warmup-body" class="mt-3">
<button <button
type="button" type="button"
:class="[ :class="[
@@ -409,15 +453,27 @@
<!-- Proxy --> <!-- Proxy -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<label class="input-label mb-0">{{ t('admin.accounts.proxy') }}</label> <label
id="bulk-edit-proxy-label"
class="input-label mb-0"
for="bulk-edit-proxy-enabled"
>
{{ t('admin.accounts.proxy') }}
</label>
<input <input
v-model="enableProxy" v-model="enableProxy"
id="bulk-edit-proxy-enabled"
type="checkbox" type="checkbox"
aria-controls="bulk-edit-proxy-body"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/> />
</div> </div>
<div :class="!enableProxy && 'pointer-events-none opacity-50'"> <div id="bulk-edit-proxy-body" :class="!enableProxy && 'pointer-events-none opacity-50'">
<ProxySelector v-model="proxyId" :proxies="proxies" /> <ProxySelector
v-model="proxyId"
:proxies="proxies"
aria-labelledby="bulk-edit-proxy-label"
/>
</div> </div>
</div> </div>
@@ -425,38 +481,58 @@
<div class="grid grid-cols-2 gap-4 border-t border-gray-200 pt-4 dark:border-dark-600"> <div class="grid grid-cols-2 gap-4 border-t border-gray-200 pt-4 dark:border-dark-600">
<div> <div>
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<label class="input-label mb-0">{{ t('admin.accounts.concurrency') }}</label> <label
id="bulk-edit-concurrency-label"
class="input-label mb-0"
for="bulk-edit-concurrency-enabled"
>
{{ t('admin.accounts.concurrency') }}
</label>
<input <input
v-model="enableConcurrency" v-model="enableConcurrency"
id="bulk-edit-concurrency-enabled"
type="checkbox" type="checkbox"
aria-controls="bulk-edit-concurrency"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/> />
</div> </div>
<input <input
v-model.number="concurrency" v-model.number="concurrency"
id="bulk-edit-concurrency"
type="number" type="number"
min="1" min="1"
:disabled="!enableConcurrency" :disabled="!enableConcurrency"
class="input" class="input"
:class="!enableConcurrency && 'cursor-not-allowed opacity-50'" :class="!enableConcurrency && 'cursor-not-allowed opacity-50'"
aria-labelledby="bulk-edit-concurrency-label"
/> />
</div> </div>
<div> <div>
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<label class="input-label mb-0">{{ t('admin.accounts.priority') }}</label> <label
id="bulk-edit-priority-label"
class="input-label mb-0"
for="bulk-edit-priority-enabled"
>
{{ t('admin.accounts.priority') }}
</label>
<input <input
v-model="enablePriority" v-model="enablePriority"
id="bulk-edit-priority-enabled"
type="checkbox" type="checkbox"
aria-controls="bulk-edit-priority"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/> />
</div> </div>
<input <input
v-model.number="priority" v-model.number="priority"
id="bulk-edit-priority"
type="number" type="number"
min="1" min="1"
:disabled="!enablePriority" :disabled="!enablePriority"
class="input" class="input"
:class="!enablePriority && 'cursor-not-allowed opacity-50'" :class="!enablePriority && 'cursor-not-allowed opacity-50'"
aria-labelledby="bulk-edit-priority-label"
/> />
</div> </div>
</div> </div>
@@ -464,39 +540,69 @@
<!-- Status --> <!-- Status -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<label class="input-label mb-0">{{ t('common.status') }}</label> <label
id="bulk-edit-status-label"
class="input-label mb-0"
for="bulk-edit-status-enabled"
>
{{ t('common.status') }}
</label>
<input <input
v-model="enableStatus" v-model="enableStatus"
id="bulk-edit-status-enabled"
type="checkbox" type="checkbox"
aria-controls="bulk-edit-status"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/> />
</div> </div>
<div :class="!enableStatus && 'pointer-events-none opacity-50'"> <div id="bulk-edit-status" :class="!enableStatus && 'pointer-events-none opacity-50'">
<Select v-model="status" :options="statusOptions" /> <Select
v-model="status"
:options="statusOptions"
aria-labelledby="bulk-edit-status-label"
/>
</div> </div>
</div> </div>
<!-- Groups --> <!-- Groups -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<label class="input-label mb-0">{{ t('nav.groups') }}</label> <label
id="bulk-edit-groups-label"
class="input-label mb-0"
for="bulk-edit-groups-enabled"
>
{{ t('nav.groups') }}
</label>
<input <input
v-model="enableGroups" v-model="enableGroups"
id="bulk-edit-groups-enabled"
type="checkbox" type="checkbox"
aria-controls="bulk-edit-groups"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/> />
</div> </div>
<div :class="!enableGroups && 'pointer-events-none opacity-50'"> <div id="bulk-edit-groups" :class="!enableGroups && 'pointer-events-none opacity-50'">
<GroupSelector v-model="groupIds" :groups="groups" /> <GroupSelector
v-model="groupIds"
:groups="groups"
aria-labelledby="bulk-edit-groups-label"
/>
</div> </div>
</div> </div>
</form>
<!-- Action buttons --> <template #footer>
<div class="flex justify-end gap-3 pt-4"> <div class="flex justify-end gap-3">
<button type="button" class="btn btn-secondary" @click="handleClose"> <button type="button" class="btn btn-secondary" @click="handleClose">
{{ t('common.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button type="submit" :disabled="submitting" class="btn btn-primary"> <button
type="submit"
form="bulk-edit-account-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"
@@ -522,8 +628,8 @@
}} }}
</button> </button>
</div> </div>
</form> </template>
</Modal> </BaseDialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -532,7 +638,7 @@ import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import type { Proxy, Group } from '@/types' import type { Proxy, Group } from '@/types'
import Modal from '@/components/common/Modal.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue' import Select from '@/components/common/Select.vue'
import ProxySelector from '@/components/common/ProxySelector.vue' import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue'

View File

@@ -1,5 +1,10 @@
<template> <template>
<Modal :show="show" :title="t('admin.accounts.createAccount')" size="xl" @close="handleClose"> <BaseDialog
:show="show"
:title="t('admin.accounts.createAccount')"
width="wide"
@close="handleClose"
>
<!-- Step Indicator for OAuth accounts --> <!-- Step Indicator for OAuth accounts -->
<div v-if="isOAuthFlow" class="mb-6 flex items-center justify-center"> <div v-if="isOAuthFlow" class="mb-6 flex items-center justify-center">
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
@@ -34,7 +39,12 @@
</div> </div>
<!-- Step 1: Basic Info --> <!-- Step 1: Basic Info -->
<form v-if="step === 1" @submit.prevent="handleSubmit" class="space-y-5"> <form
v-if="step === 1"
id="create-account-form"
@submit.prevent="handleSubmit"
class="space-y-5"
>
<div> <div>
<label class="input-label">{{ t('admin.accounts.accountName') }}</label> <label class="input-label">{{ t('admin.accounts.accountName') }}</label>
<input <input
@@ -1018,11 +1028,40 @@
<!-- Group Selection --> <!-- Group Selection -->
<GroupSelector v-model="form.group_ids" :groups="groups" :platform="form.platform" /> <GroupSelector v-model="form.group_ids" :groups="groups" :platform="form.platform" />
<div class="flex justify-end gap-3 pt-4"> </form>
<!-- Step 2: OAuth Authorization -->
<div v-else class="space-y-5">
<OAuthAuthorizationFlow
ref="oauthFlowRef"
:add-method="form.platform === 'anthropic' ? addMethod : 'oauth'"
:auth-url="currentAuthUrl"
:session-id="currentSessionId"
:loading="currentOAuthLoading"
:error="currentOAuthError"
:show-help="form.platform === 'anthropic'"
:show-proxy-warning="form.platform !== 'openai' && !!form.proxy_id"
:allow-multiple="form.platform === 'anthropic'"
:show-cookie-option="form.platform === 'anthropic'"
:platform="form.platform"
:show-project-id="geminiOAuthType === 'code_assist'"
@generate-url="handleGenerateUrl"
@cookie-auth="handleCookieAuth"
/>
</div>
<template #footer>
<div v-if="step === 1" class="flex justify-end gap-3">
<button @click="handleClose" type="button" class="btn btn-secondary"> <button @click="handleClose" 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-account-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"
@@ -1052,28 +1091,7 @@
}} }}
</button> </button>
</div> </div>
</form> <div v-else class="flex justify-between gap-3">
<!-- Step 2: OAuth Authorization -->
<div v-else class="space-y-5">
<OAuthAuthorizationFlow
ref="oauthFlowRef"
:add-method="form.platform === 'anthropic' ? addMethod : 'oauth'"
:auth-url="currentAuthUrl"
:session-id="currentSessionId"
:loading="currentOAuthLoading"
:error="currentOAuthError"
:show-help="form.platform === 'anthropic'"
:show-proxy-warning="form.platform !== 'openai' && !!form.proxy_id"
:allow-multiple="form.platform === 'anthropic'"
:show-cookie-option="form.platform === 'anthropic'"
:platform="form.platform"
:show-project-id="geminiOAuthType === 'code_assist'"
@generate-url="handleGenerateUrl"
@cookie-auth="handleCookieAuth"
/>
<div class="flex justify-between gap-3 pt-4">
<button type="button" class="btn btn-secondary" @click="goBackToBasicInfo"> <button type="button" class="btn btn-secondary" @click="goBackToBasicInfo">
{{ t('common.back') }} {{ t('common.back') }}
</button> </button>
@@ -1111,8 +1129,8 @@
}} }}
</button> </button>
</div> </div>
</div> </template>
</Modal> </BaseDialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -1129,7 +1147,7 @@ import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
import { useGeminiOAuth } from '@/composables/useGeminiOAuth' import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth' import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth'
import type { Proxy, Group, AccountPlatform, AccountType } from '@/types' import type { Proxy, Group, AccountPlatform, AccountType } from '@/types'
import Modal from '@/components/common/Modal.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import ProxySelector from '@/components/common/ProxySelector.vue' import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue'
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue' import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'

View File

@@ -1,6 +1,16 @@
<template> <template>
<Modal :show="show" :title="t('admin.accounts.editAccount')" size="xl" @close="handleClose"> <BaseDialog
<form v-if="account" @submit.prevent="handleSubmit" class="space-y-5"> :show="show"
:title="t('admin.accounts.editAccount')"
width="wide"
@close="handleClose"
>
<form
v-if="account"
id="edit-account-form"
@submit.prevent="handleSubmit"
class="space-y-5"
>
<div> <div>
<label class="input-label">{{ t('common.name') }}</label> <label class="input-label">{{ t('common.name') }}</label>
<input v-model="form.name" type="text" required class="input" /> <input v-model="form.name" type="text" required class="input" />
@@ -459,11 +469,19 @@
<!-- Group Selection --> <!-- Group Selection -->
<GroupSelector v-model="form.group_ids" :groups="groups" :platform="account?.platform" /> <GroupSelector v-model="form.group_ids" :groups="groups" :platform="account?.platform" />
<div class="flex justify-end gap-3 pt-4"> </form>
<template #footer>
<div v-if="account" class="flex justify-end gap-3">
<button @click="handleClose" type="button" class="btn btn-secondary"> <button @click="handleClose" 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-account-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"
@@ -487,8 +505,8 @@
{{ submitting ? t('admin.accounts.updating') : t('common.update') }} {{ submitting ? t('admin.accounts.updating') : t('common.update') }}
</button> </button>
</div> </div>
</form> </template>
</Modal> </BaseDialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -497,7 +515,7 @@ import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import type { Account, Proxy, Group } from '@/types' import type { Account, Proxy, Group } from '@/types'
import Modal from '@/components/common/Modal.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue' import Select from '@/components/common/Select.vue'
import ProxySelector from '@/components/common/ProxySelector.vue' import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue'

View File

@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="rounded-lg border border-blue-200 bg-blue-50 p-6 dark:border-blue-700 dark:bg-blue-900/30" class="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-700 dark:bg-blue-900/30"
> >
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-blue-500"> <div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-blue-500">

View File

@@ -1,11 +1,11 @@
<template> <template>
<Modal <BaseDialog
:show="show" :show="show"
:title="t('admin.accounts.reAuthorizeAccount')" :title="t('admin.accounts.reAuthorizeAccount')"
size="lg" width="wide"
@close="handleClose" @close="handleClose"
> >
<div v-if="account" class="space-y-5"> <div v-if="account" class="space-y-4">
<!-- Account Info --> <!-- Account Info -->
<div <div
class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-700" class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-700"
@@ -53,8 +53,8 @@
</div> </div>
<!-- Add Method Selection (Claude only) --> <!-- Add Method Selection (Claude only) -->
<div v-if="isAnthropic"> <fieldset v-if="isAnthropic" class="border-0 p-0">
<label class="input-label">{{ t('admin.accounts.oauth.authMethod') }}</label> <legend class="input-label">{{ t('admin.accounts.oauth.authMethod') }}</legend>
<div class="mt-2 flex gap-4"> <div class="mt-2 flex gap-4">
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input <input
@@ -79,11 +79,11 @@
}}</span> }}</span>
</label> </label>
</div> </div>
</div> </fieldset>
<!-- Gemini OAuth Type Selection --> <!-- Gemini OAuth Type Selection -->
<div v-if="isGemini"> <fieldset v-if="isGemini" class="border-0 p-0">
<label class="input-label">{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}</label> <legend class="input-label">{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}</legend>
<div class="mt-2 grid grid-cols-2 gap-3"> <div class="mt-2 grid grid-cols-2 gap-3">
<button <button
type="button" type="button"
@@ -187,7 +187,7 @@
</div> </div>
</button> </button>
</div> </div>
</div> </fieldset>
<OAuthAuthorizationFlow <OAuthAuthorizationFlow
ref="oauthFlowRef" ref="oauthFlowRef"
@@ -207,7 +207,10 @@
@cookie-auth="handleCookieAuth" @cookie-auth="handleCookieAuth"
/> />
<div class="flex justify-between gap-3 pt-4"> </div>
<template #footer>
<div v-if="account" class="flex justify-between gap-3">
<button type="button" class="btn btn-secondary" @click="handleClose"> <button type="button" class="btn btn-secondary" @click="handleClose">
{{ t('common.cancel') }} {{ t('common.cancel') }}
</button> </button>
@@ -245,8 +248,8 @@
}} }}
</button> </button>
</div> </div>
</div> </template>
</Modal> </BaseDialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -262,7 +265,7 @@ import {
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth' import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
import { useGeminiOAuth } from '@/composables/useGeminiOAuth' import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
import type { Account } from '@/types' import type { Account } from '@/types'
import Modal from '@/components/common/Modal.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue' import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
// Type for exposed OAuthAuthorizationFlow component // Type for exposed OAuthAuthorizationFlow component

View File

@@ -1,12 +1,12 @@
<template> <template>
<Modal <BaseDialog
:show="show" :show="show"
:title="t('admin.accounts.syncFromCrsTitle')" :title="t('admin.accounts.syncFromCrsTitle')"
size="lg" width="normal"
close-on-click-outside close-on-click-outside
@close="handleClose" @close="handleClose"
> >
<div class="space-y-4"> <form id="sync-from-crs-form" class="space-y-4" @submit.prevent="handleSync">
<div class="text-sm text-gray-600 dark:text-dark-300"> <div class="text-sm text-gray-600 dark:text-dark-300">
{{ t('admin.accounts.syncFromCrsDesc') }} {{ t('admin.accounts.syncFromCrsDesc') }}
</div> </div>
@@ -84,25 +84,30 @@
</div> </div>
</div> </div>
</div> </div>
</div> </form>
<template #footer> <template #footer>
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">
<button class="btn btn-secondary" :disabled="syncing" @click="handleClose"> <button class="btn btn-secondary" type="button" :disabled="syncing" @click="handleClose">
{{ t('common.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button class="btn btn-primary" :disabled="syncing" @click="handleSync"> <button
class="btn btn-primary"
type="submit"
form="sync-from-crs-form"
:disabled="syncing"
>
{{ syncing ? t('admin.accounts.syncing') : t('admin.accounts.syncNow') }} {{ syncing ? t('admin.accounts.syncing') : t('admin.accounts.syncNow') }}
</button> </button>
</div> </div>
</template> </template>
</Modal> </BaseDialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import Modal from '@/components/common/Modal.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'

View File

@@ -0,0 +1,118 @@
<template>
<Teleport to="body">
<div
v-if="show"
class="modal-overlay"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true"
@click.self="handleClose"
>
<!-- Modal panel -->
<div :class="['modal-content', widthClasses]" @click.stop>
<!-- Header -->
<div class="modal-header">
<h3 id="modal-title" class="modal-title">
{{ title }}
</h3>
<button
@click="emit('close')"
class="-mr-2 rounded-xl p-2 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:text-dark-500 dark:hover:bg-dark-700 dark:hover:text-dark-300"
aria-label="Close modal"
>
<svg
class="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Body -->
<div class="modal-body">
<slot></slot>
</div>
<!-- Footer -->
<div v-if="$slots.footer" class="modal-footer">
<slot name="footer"></slot>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, watch, onMounted, onUnmounted } from 'vue'
type DialogWidth = 'narrow' | 'normal' | 'wide' | 'extra-wide' | 'full'
interface Props {
show: boolean
title: string
width?: DialogWidth
closeOnEscape?: boolean
closeOnClickOutside?: boolean
}
interface Emits {
(e: 'close'): void
}
const props = withDefaults(defineProps<Props>(), {
width: 'normal',
closeOnEscape: true,
closeOnClickOutside: false
})
const emit = defineEmits<Emits>()
const widthClasses = computed(() => {
const widths: Record<DialogWidth, string> = {
narrow: 'max-w-md',
normal: 'max-w-lg',
wide: 'max-w-4xl',
'extra-wide': 'max-w-6xl',
full: 'max-w-7xl'
}
return widths[props.width]
})
const handleClose = () => {
if (props.closeOnClickOutside) {
emit('close')
}
}
const handleEscape = (event: KeyboardEvent) => {
if (props.show && props.closeOnEscape && event.key === 'Escape') {
emit('close')
}
}
// Prevent body scroll when modal is open
watch(
() => props.show,
(isOpen) => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
},
{ immediate: true }
)
onMounted(() => {
document.addEventListener('keydown', handleEscape)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleEscape)
document.body.style.overflow = ''
})
</script>

View File

@@ -1,5 +1,5 @@
<template> <template>
<Modal :show="show" :title="title" size="sm" @close="handleCancel"> <BaseDialog :show="show" :title="title" width="narrow" @close="handleCancel">
<div class="space-y-4"> <div class="space-y-4">
<p class="text-sm text-gray-600 dark:text-gray-400">{{ message }}</p> <p class="text-sm text-gray-600 dark:text-gray-400">{{ message }}</p>
</div> </div>
@@ -27,13 +27,13 @@
</button> </button>
</div> </div>
</template> </template>
</Modal> </BaseDialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import Modal from './Modal.vue' import BaseDialog from './BaseDialog.vue'
const { t } = useI18n() const { t } = useI18n()

View File

@@ -24,37 +24,6 @@
> >
<div class="flex items-center space-x-1"> <div class="flex items-center space-x-1">
<span>{{ column.label }}</span> <span>{{ column.label }}</span>
<!-- 操作列展开/折叠按钮 -->
<button
v-if="column.key === 'actions' && hasExpandableActions"
type="button"
@click.stop="toggleActionsExpanded"
class="ml-2 flex items-center justify-center rounded p-1 text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-dark-600 dark:hover:text-gray-300"
:title="actionsExpanded ? t('table.collapseActions') : t('table.expandActions')"
>
<!-- 展开状态收起图标 -->
<svg
v-if="actionsExpanded"
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M18.75 19.5l-7.5-7.5 7.5-7.5m-6 15L5.25 12l7.5-7.5" />
</svg>
<!-- 折叠状态展开图标 -->
<svg
v-else
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 4.5l7.5 7.5-7.5 7.5m-6-15l7.5 7.5-7.5 7.5" />
</svg>
</button>
<span v-if="column.sortable" class="text-gray-400 dark:text-dark-500"> <span v-if="column.sortable" class="text-gray-400 dark:text-dark-500">
<svg <svg
v-if="sortKey === column.key" v-if="sortKey === column.key"
@@ -182,8 +151,8 @@ const checkActionsColumnWidth = () => {
// 等待DOM更新 // 等待DOM更新
nextTick(() => { nextTick(() => {
// 测量所有按钮的总宽度 // 测量所有按钮的总宽度
const buttons = actionsContainer.querySelectorAll('button') const actionItems = actionsContainer.querySelectorAll('button, a, [role="button"]')
if (buttons.length <= 2) { if (actionItems.length <= 2) {
actionsColumnNeedsExpanding.value = false actionsColumnNeedsExpanding.value = false
actionsExpanded.value = wasExpanded actionsExpanded.value = wasExpanded
return return
@@ -191,9 +160,9 @@ const checkActionsColumnWidth = () => {
// 计算所有按钮的总宽度包括gap // 计算所有按钮的总宽度包括gap
let totalWidth = 0 let totalWidth = 0
buttons.forEach((btn, index) => { actionItems.forEach((item, index) => {
totalWidth += (btn as HTMLElement).offsetWidth totalWidth += (item as HTMLElement).offsetWidth
if (index < buttons.length - 1) { if (index < actionItems.length - 1) {
totalWidth += 4 // gap-1 = 4px totalWidth += 4 // gap-1 = 4px
} }
}) })
@@ -211,6 +180,7 @@ const checkActionsColumnWidth = () => {
// 监听尺寸变化 // 监听尺寸变化
let resizeObserver: ResizeObserver | null = null let resizeObserver: ResizeObserver | null = null
let resizeHandler: (() => void) | null = null
onMounted(() => { onMounted(() => {
checkScrollable() checkScrollable()
@@ -223,17 +193,20 @@ onMounted(() => {
resizeObserver.observe(tableWrapperRef.value) resizeObserver.observe(tableWrapperRef.value)
} else { } else {
// 降级方案:不支持 ResizeObserver 时使用 window resize // 降级方案:不支持 ResizeObserver 时使用 window resize
const handleResize = () => { resizeHandler = () => {
checkScrollable() checkScrollable()
checkActionsColumnWidth() checkActionsColumnWidth()
} }
window.addEventListener('resize', handleResize) window.addEventListener('resize', resizeHandler)
} }
}) })
onUnmounted(() => { onUnmounted(() => {
resizeObserver?.disconnect() resizeObserver?.disconnect()
window.removeEventListener('resize', checkScrollable) if (resizeHandler) {
window.removeEventListener('resize', resizeHandler)
resizeHandler = null
}
}) })
interface Props { interface Props {
@@ -298,26 +271,6 @@ const sortedData = computed(() => {
}) })
}) })
// 检查是否有可展开的操作列
const hasExpandableActions = computed(() => {
// 如果明确指定了actionsCount使用它来判断
if (props.actionsCount !== undefined) {
return props.expandableActions && props.columns.some((col) => col.key === 'actions') && props.actionsCount > 2
}
// 否则使用原来的检测逻辑
return (
props.expandableActions &&
props.columns.some((col) => col.key === 'actions') &&
actionsColumnNeedsExpanding.value
)
})
// 切换操作列展开/折叠状态
const toggleActionsExpanded = () => {
actionsExpanded.value = !actionsExpanded.value
}
// 检查第一列是否为勾选列 // 检查第一列是否为勾选列
const hasSelectColumn = computed(() => { const hasSelectColumn = computed(() => {
return props.columns.length > 0 && props.columns[0].key === 'select' return props.columns.length > 0 && props.columns[0].key === 'select'

View File

@@ -206,10 +206,6 @@ const handlePageSizeChange = (value: string | number | boolean | null) => {
if (value === null || typeof value === 'boolean') return if (value === null || typeof value === 'boolean') return
const newPageSize = typeof value === 'string' ? parseInt(value) : value const newPageSize = typeof value === 'string' ? parseInt(value) : value
emit('update:pageSize', newPageSize) emit('update:pageSize', newPageSize)
// Reset to first page when page size changes
if (props.page !== 1) {
emit('update:page', 1)
}
} }
</script> </script>

View File

@@ -30,7 +30,11 @@
</button> </button>
<Transition name="select-dropdown"> <Transition name="select-dropdown">
<div v-if="isOpen" class="select-dropdown"> <div
v-if="isOpen"
ref="dropdownRef"
:class="['select-dropdown', dropdownPosition === 'top' && 'select-dropdown-top']"
>
<!-- Search input --> <!-- Search input -->
<div v-if="searchable" class="select-search"> <div v-if="searchable" class="select-search">
<svg <svg
@@ -141,6 +145,8 @@ const isOpen = ref(false)
const searchQuery = ref('') const searchQuery = ref('')
const containerRef = ref<HTMLElement | null>(null) const containerRef = ref<HTMLElement | null>(null)
const searchInputRef = ref<HTMLInputElement | null>(null) const searchInputRef = ref<HTMLInputElement | null>(null)
const dropdownRef = ref<HTMLElement | null>(null)
const dropdownPosition = ref<'bottom' | 'top'>('bottom')
const getOptionValue = ( const getOptionValue = (
option: SelectOption | Record<string, unknown> option: SelectOption | Record<string, unknown>
@@ -184,14 +190,38 @@ const isSelected = (option: SelectOption | Record<string, unknown>): boolean =>
return getOptionValue(option) === props.modelValue return getOptionValue(option) === props.modelValue
} }
const calculateDropdownPosition = () => {
if (!containerRef.value) return
nextTick(() => {
if (!containerRef.value || !dropdownRef.value) return
const triggerRect = containerRef.value.getBoundingClientRect()
const dropdownHeight = dropdownRef.value.offsetHeight || 240 // Max height fallback
const viewportHeight = window.innerHeight
const spaceBelow = viewportHeight - triggerRect.bottom
const spaceAbove = triggerRect.top
// If not enough space below but enough space above, show dropdown on top
if (spaceBelow < dropdownHeight && spaceAbove > dropdownHeight) {
dropdownPosition.value = 'top'
} else {
dropdownPosition.value = 'bottom'
}
})
}
const toggle = () => { const toggle = () => {
if (props.disabled) return if (props.disabled) return
isOpen.value = !isOpen.value isOpen.value = !isOpen.value
if (isOpen.value && props.searchable) { if (isOpen.value) {
calculateDropdownPosition()
if (props.searchable) {
nextTick(() => { nextTick(() => {
searchInputRef.value?.focus() searchInputRef.value?.focus()
}) })
} }
}
} }
const selectOption = (option: SelectOption | Record<string, unknown>) => { const selectOption = (option: SelectOption | Record<string, unknown>) => {
@@ -275,6 +305,10 @@ onUnmounted(() => {
@apply overflow-hidden; @apply overflow-hidden;
} }
.select-dropdown-top {
@apply bottom-full mb-2 mt-0;
}
.select-search { .select-search {
@apply flex items-center gap-2 px-3 py-2; @apply flex items-center gap-2 px-3 py-2;
@apply border-b border-gray-100 dark:border-dark-700; @apply border-b border-gray-100 dark:border-dark-700;
@@ -322,6 +356,17 @@ onUnmounted(() => {
.select-dropdown-enter-from, .select-dropdown-enter-from,
.select-dropdown-leave-to { .select-dropdown-leave-to {
opacity: 0; opacity: 0;
}
/* Animation for dropdown opening downward (default) */
.select-dropdown:not(.select-dropdown-top).select-dropdown-enter-from,
.select-dropdown:not(.select-dropdown-top).select-dropdown-leave-to {
transform: translateY(-8px); transform: translateY(-8px);
} }
/* Animation for dropdown opening upward */
.select-dropdown-top.select-dropdown-enter-from,
.select-dropdown-top.select-dropdown-leave-to {
transform: translateY(8px);
}
</style> </style>

View File

@@ -178,17 +178,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue' import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import subscriptionsAPI from '@/api/subscriptions' import { useSubscriptionStore } from '@/stores'
import type { UserSubscription } from '@/types' import type { UserSubscription } from '@/types'
const { t } = useI18n() const { t } = useI18n()
const subscriptionStore = useSubscriptionStore()
const containerRef = ref<HTMLElement | null>(null) const containerRef = ref<HTMLElement | null>(null)
const tooltipOpen = ref(false) const tooltipOpen = ref(false)
const activeSubscriptions = ref<UserSubscription[]>([])
const loading = ref(false)
const hasActiveSubscriptions = computed(() => activeSubscriptions.value.length > 0) // Use store data instead of local state
const activeSubscriptions = computed(() => subscriptionStore.activeSubscriptions)
const hasActiveSubscriptions = computed(() => subscriptionStore.hasActiveSubscriptions)
const displaySubscriptions = computed(() => { const displaySubscriptions = computed(() => {
// Sort by most usage (highest percentage first) // Sort by most usage (highest percentage first)
@@ -275,37 +277,18 @@ function handleClickOutside(event: MouseEvent) {
} }
} }
async function loadSubscriptions() {
try {
loading.value = true
activeSubscriptions.value = await subscriptionsAPI.getActiveSubscriptions()
} catch (error) {
console.error('Failed to load subscriptions:', error)
activeSubscriptions.value = []
} finally {
loading.value = false
}
}
onMounted(() => { onMounted(() => {
document.addEventListener('click', handleClickOutside) document.addEventListener('click', handleClickOutside)
loadSubscriptions() // Trigger initial fetch if not already loaded
// The actual data loading is handled by App.vue globally
subscriptionStore.fetchActiveSubscriptions().catch((error) => {
console.error('Failed to load subscriptions in SubscriptionProgressMini:', error)
})
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside) document.removeEventListener('click', handleClickOutside)
}) })
// Refresh subscriptions periodically (every 5 minutes)
let refreshInterval: ReturnType<typeof setInterval> | null = null
onMounted(() => {
refreshInterval = setInterval(loadSubscriptions, 5 * 60 * 1000)
})
onBeforeUnmount(() => {
if (refreshInterval) {
clearInterval(refreshInterval)
}
})
</script> </script>
<style scoped> <style scoped>

View File

@@ -2,6 +2,7 @@
export { default as DataTable } from './DataTable.vue' export { default as DataTable } from './DataTable.vue'
export { default as Pagination } from './Pagination.vue' export { default as Pagination } from './Pagination.vue'
export { default as Modal } from './Modal.vue' export { default as Modal } from './Modal.vue'
export { default as BaseDialog } from './BaseDialog.vue'
export { default as ConfirmDialog } from './ConfirmDialog.vue' export { default as ConfirmDialog } from './ConfirmDialog.vue'
export { default as StatCard } from './StatCard.vue' export { default as StatCard } from './StatCard.vue'
export { default as Toast } from './Toast.vue' export { default as Toast } from './Toast.vue'

View File

@@ -1,8 +1,8 @@
<template> <template>
<Modal <BaseDialog
:show="show" :show="show"
:title="t('keys.useKeyModal.title')" :title="t('keys.useKeyModal.title')"
size="lg" width="wide"
@close="emit('close')" @close="emit('close')"
> >
<div class="space-y-4"> <div class="space-y-4">
@@ -112,13 +112,13 @@
</button> </button>
</div> </div>
</template> </template>
</Modal> </BaseDialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, h, watch, type Component } from 'vue' import { ref, computed, h, watch, type Component } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import Modal from '@/components/common/Modal.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import { useClipboard } from '@/composables/useClipboard' import { useClipboard } from '@/composables/useClipboard'
import type { GroupPlatform } from '@/types' import type { GroupPlatform } from '@/types'

View File

@@ -119,6 +119,7 @@ export default {
info: 'Info', info: 'Info',
active: 'Active', active: 'Active',
inactive: 'Inactive', inactive: 'Inactive',
more: 'More',
close: 'Close', close: 'Close',
enabled: 'Enabled', enabled: 'Enabled',
disabled: 'Disabled', disabled: 'Disabled',
@@ -344,6 +345,8 @@ export default {
allApiKeys: 'All API Keys', allApiKeys: 'All API Keys',
timeRange: 'Time Range', timeRange: 'Time Range',
exportCsv: 'Export CSV', exportCsv: 'Export CSV',
exporting: 'Exporting...',
preparingExport: 'Preparing export...',
model: 'Model', model: 'Model',
type: 'Type', type: 'Type',
tokens: 'Tokens', tokens: 'Tokens',
@@ -364,6 +367,7 @@ export default {
failedToLoad: 'Failed to load usage logs', failedToLoad: 'Failed to load usage logs',
noDataToExport: 'No data to export', noDataToExport: 'No data to export',
exportSuccess: 'Usage data exported successfully', exportSuccess: 'Usage data exported successfully',
exportFailed: 'Failed to export usage data',
billingType: 'Billing', billingType: 'Billing',
balance: 'Balance', balance: 'Balance',
subscription: 'Subscription' subscription: 'Subscription'
@@ -406,7 +410,8 @@ export default {
subscriptionDays: '{days} days', subscriptionDays: '{days} days',
days: ' days', days: ' days',
codeRedeemSuccess: 'Code redeemed successfully!', codeRedeemSuccess: 'Code redeemed successfully!',
failedToRedeem: 'Failed to redeem code. Please check the code and try again.' failedToRedeem: 'Failed to redeem code. Please check the code and try again.',
subscriptionRefreshFailed: 'Redeemed successfully, but failed to refresh subscription status.'
}, },
// Profile // Profile
@@ -427,6 +432,7 @@ export default {
updating: 'Updating...', updating: 'Updating...',
updateSuccess: 'Profile updated successfully', updateSuccess: 'Profile updated successfully',
updateFailed: 'Failed to update profile', updateFailed: 'Failed to update profile',
usernameRequired: 'Username is required',
changePassword: 'Change Password', changePassword: 'Change Password',
currentPassword: 'Current Password', currentPassword: 'Current Password',
newPassword: 'New Password', newPassword: 'New Password',

View File

@@ -116,6 +116,7 @@ export default {
info: '提示', info: '提示',
active: '启用', active: '启用',
inactive: '禁用', inactive: '禁用',
more: '更多',
close: '关闭', close: '关闭',
enabled: '已启用', enabled: '已启用',
disabled: '已禁用', disabled: '已禁用',
@@ -340,6 +341,8 @@ export default {
allApiKeys: '全部密钥', allApiKeys: '全部密钥',
timeRange: '时间范围', timeRange: '时间范围',
exportCsv: '导出 CSV', exportCsv: '导出 CSV',
exporting: '导出中...',
preparingExport: '正在准备导出...',
model: '模型', model: '模型',
type: '类型', type: '类型',
tokens: 'Token', tokens: 'Token',
@@ -360,6 +363,7 @@ export default {
failedToLoad: '加载使用记录失败', failedToLoad: '加载使用记录失败',
noDataToExport: '没有可导出的数据', noDataToExport: '没有可导出的数据',
exportSuccess: '使用数据导出成功', exportSuccess: '使用数据导出成功',
exportFailed: '使用数据导出失败',
billingType: '消费类型', billingType: '消费类型',
balance: '余额', balance: '余额',
subscription: '订阅' subscription: '订阅'
@@ -402,7 +406,8 @@ export default {
subscriptionDays: '{days} 天', subscriptionDays: '{days} 天',
days: '天', days: '天',
codeRedeemSuccess: '兑换成功!', codeRedeemSuccess: '兑换成功!',
failedToRedeem: '兑换失败,请检查兑换码后重试。' failedToRedeem: '兑换失败,请检查兑换码后重试。',
subscriptionRefreshFailed: '兑换成功,但订阅状态刷新失败。'
}, },
// Profile // Profile
@@ -423,6 +428,7 @@ export default {
updating: '更新中...', updating: '更新中...',
updateSuccess: '资料更新成功', updateSuccess: '资料更新成功',
updateFailed: '资料更新失败', updateFailed: '资料更新失败',
usernameRequired: '用户名不能为空',
changePassword: '修改密码', changePassword: '修改密码',
currentPassword: '当前密码', currentPassword: '当前密码',
newPassword: '新密码', newPassword: '新密码',

View File

@@ -5,6 +5,7 @@
export { useAuthStore } from './auth' export { useAuthStore } from './auth'
export { useAppStore } from './app' export { useAppStore } from './app'
export { useSubscriptionStore } from './subscriptions'
// Re-export types for convenience // Re-export types for convenience
export type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types' export type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types'

View File

@@ -0,0 +1,140 @@
/**
* Subscription Store
* Global state management for user subscriptions with caching and deduplication
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import subscriptionsAPI from '@/api/subscriptions'
import type { UserSubscription } from '@/types'
// Cache TTL: 60 seconds
const CACHE_TTL_MS = 60_000
// Request generation counter to invalidate stale in-flight responses
let requestGeneration = 0
export const useSubscriptionStore = defineStore('subscriptions', () => {
// State
const activeSubscriptions = ref<UserSubscription[]>([])
const loading = ref(false)
const loaded = ref(false)
const lastFetchedAt = ref<number | null>(null)
// In-flight request deduplication
let activePromise: Promise<UserSubscription[]> | null = null
// Auto-refresh interval
let pollerInterval: ReturnType<typeof setInterval> | null = null
// Computed
const hasActiveSubscriptions = computed(() => activeSubscriptions.value.length > 0)
/**
* Fetch active subscriptions with caching and deduplication
* @param force - Force refresh even if cache is valid
*/
async function fetchActiveSubscriptions(force = false): Promise<UserSubscription[]> {
const now = Date.now()
// Return cached data if valid
if (
!force &&
loaded.value &&
lastFetchedAt.value &&
now - lastFetchedAt.value < CACHE_TTL_MS
) {
return activeSubscriptions.value
}
// Return in-flight request if exists (deduplication)
if (activePromise && !force) {
return activePromise
}
const currentGeneration = ++requestGeneration
// Start new request
loading.value = true
const requestPromise = subscriptionsAPI
.getActiveSubscriptions()
.then((data) => {
if (currentGeneration === requestGeneration) {
activeSubscriptions.value = data
loaded.value = true
lastFetchedAt.value = Date.now()
}
return data
})
.catch((error) => {
console.error('Failed to fetch active subscriptions:', error)
throw error
})
.finally(() => {
if (activePromise === requestPromise) {
loading.value = false
activePromise = null
}
})
activePromise = requestPromise
return activePromise
}
/**
* Start auto-refresh polling
*/
function startPolling() {
if (pollerInterval) return
pollerInterval = setInterval(() => {
fetchActiveSubscriptions(true).catch((error) => {
console.error('Subscription polling failed:', error)
})
}, 5 * 60 * 1000)
}
/**
* Stop auto-refresh polling
*/
function stopPolling() {
if (pollerInterval) {
clearInterval(pollerInterval)
pollerInterval = null
}
}
/**
* Clear all subscription data and stop polling
*/
function clear() {
requestGeneration++
activePromise = null
activeSubscriptions.value = []
loaded.value = false
lastFetchedAt.value = null
stopPolling()
}
/**
* Invalidate cache (force next fetch to reload)
*/
function invalidateCache() {
lastFetchedAt.value = null
}
return {
// State
activeSubscriptions,
loading,
hasActiveSubscriptions,
// Actions
fetchActiveSubscriptions,
startPolling,
stopPolling,
clear,
invalidateCache
}
})

View File

@@ -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];

View File

@@ -165,7 +165,7 @@
</div> </div>
</div> </div>
<DataTable :columns="columns" :data="accounts" :loading="loading" :actions-count="6"> <DataTable :columns="columns" :data="accounts" :loading="loading">
<template #cell-select="{ row }"> <template #cell-select="{ row }">
<input <input
type="checkbox" type="checkbox"
@@ -275,9 +275,9 @@
</span> </span>
</template> </template>
<template #cell-actions="{ row, expanded }"> <template #cell-actions="{ row }">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<!-- 主要操作编辑和删除始终显示 --> <!-- Edit Button -->
<button <button
@click="handleEdit(row)" @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 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"
@@ -297,6 +297,8 @@
</svg> </svg>
<span class="text-xs">{{ t('common.edit') }}</span> <span class="text-xs">{{ t('common.edit') }}</span>
</button> </button>
<!-- Delete Button -->
<button <button
@click="handleDelete(row)" @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" 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"
@@ -317,131 +319,28 @@
<span class="text-xs">{{ t('common.delete') }}</span> <span class="text-xs">{{ t('common.delete') }}</span>
</button> </button>
<!-- 次要操作展开时显示 --> <!-- More Actions Menu Trigger -->
<template v-if="expanded">
<!-- Reset Status button for error accounts -->
<button <button
v-if="row.status === 'error'" :ref="(el) => setActionButtonRef(row.id, el)"
@click="handleResetStatus(row)" @click="openActionMenu(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400" class="action-menu-trigger flex flex-col items-center gap-0.5 rounded-lg p-1.5 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 <svg
class="h-4 w-4" class="h-4 w-4"
fill="none" fill="none"
stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5" stroke-width="1.5"
> >
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3" 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>
<span class="text-xs">{{ t('admin.accounts.resetStatus') }}</span> <span class="text-xs">{{ t('common.more') }}</span>
</button> </button>
<!-- Clear Rate Limit button -->
<button
v-if="isRateLimited(row) || isOverloaded(row)"
@click="handleClearRateLimit(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-amber-500 transition-colors hover:bg-amber-50 hover:text-amber-600 dark:hover:bg-amber-900/20 dark:hover:text-amber-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 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="text-xs">{{ t('admin.accounts.clearRateLimit') }}</span>
</button>
<!-- Test Connection button -->
<button
@click="handleTest(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-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.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z"
/>
</svg>
<span class="text-xs">{{ t('admin.accounts.testConnection') }}</span>
</button>
<!-- View Stats button -->
<button
@click="handleViewStats(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-indigo-50 hover:text-indigo-600 dark:hover:bg-indigo-900/20 dark:hover:text-indigo-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="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
/>
</svg>
<span class="text-xs">{{ t('admin.accounts.viewStats') }}</span>
</button>
<button
v-if="row.type === 'oauth' || row.type === 'setup-token'"
@click="handleReAuth(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="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244"
/>
</svg>
<span class="text-xs">{{ t('admin.accounts.reAuthorize') }}</span>
</button>
<button
v-if="row.type === 'oauth' || row.type === 'setup-token'"
@click="handleRefreshToken(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="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
<span class="text-xs">{{ t('admin.accounts.refreshToken') }}</span>
</button>
</template>
</div> </div>
</template> </template>
@@ -463,6 +362,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"
/> />
</template> </template>
</TablePageLayout> </TablePageLayout>
@@ -537,11 +437,61 @@
@close="showBulkEditModal = false" @close="showBulkEditModal = false"
@updated="handleBulkUpdated" @updated="handleBulkUpdated"
/> />
<!-- Action Menu (Teleported) -->
<Teleport to="body">
<div
v-if="activeMenuId !== null && menuPosition"
class="action-menu-content fixed z-[9999] w-52 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 dark:bg-dark-800 dark:ring-white/10"
:style="{ top: menuPosition.top + 'px', left: menuPosition.left + 'px' }"
>
<div class="py-1">
<template v-for="account in accounts" :key="account.id">
<template v-if="account.id === activeMenuId">
<button
@click="handleTest(account); 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-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
{{ t('admin.accounts.testConnection') }}
</button>
<button
@click="handleViewStats(account); 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-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>
{{ t('admin.accounts.viewStats') }}
</button>
<template v-if="account.type === 'oauth' || account.type === 'setup-token'">
<button @click="handleReAuth(account); 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-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg>
{{ t('admin.accounts.reAuthorize') }}
</button>
<button @click="handleRefreshToken(account); 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-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h5M20 20v-5h-5M4 4l16 16" /></svg>
{{ t('admin.accounts.refreshToken') }}
</button>
</template>
<div v-if="account.status === 'error' || isRateLimited(account) || isOverloaded(account)" class="my-1 border-t border-gray-100 dark:border-dark-700"></div>
<button v-if="account.status === 'error'" @click="handleResetStatus(account); closeActionMenu()" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-yellow-600 hover:bg-gray-100 dark:text-yellow-400 dark:hover:bg-dark-700">
<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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
{{ t('admin.accounts.resetStatus') }}
</button>
<button v-if="isRateLimited(account) || isOverloaded(account)" @click="handleClearRateLimit(account); closeActionMenu()" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-amber-600 hover:bg-gray-100 dark:text-amber-400 dark:hover:bg-dark-700">
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
{{ t('admin.accounts.clearRateLimit') }}
</button>
</template>
</template>
</div>
</div>
</Teleport>
</AppLayout> </AppLayout>
</template> </template>
<script setup lang="ts"> <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 { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
@@ -629,6 +579,7 @@ const pagination = reactive({
total: 0, total: 0,
pages: 0 pages: 0
}) })
let abortController: AbortController | null = null
// Modal states // Modal states
const showCreateModal = ref(false) const showCreateModal = ref(false)
@@ -648,6 +599,49 @@ const statsAccount = ref<Account | null>(null)
const togglingSchedulable = ref<number | null>(null) const togglingSchedulable = ref<number | null>(null)
const bulkDeleting = ref(false) const bulkDeleting = ref(false)
// 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 = (accountId: number, el: Element | ComponentPublicInstance | null) => {
if (el instanceof HTMLElement) {
actionButtonRefs.value.set(accountId, el)
} else {
actionButtonRefs.value.delete(accountId)
}
}
const openActionMenu = (account: Account) => {
if (activeMenuId.value === account.id) {
closeActionMenu()
} else {
const buttonEl = actionButtonRefs.value.get(account.id)
if (buttonEl) {
const rect = buttonEl.getBoundingClientRect()
// Position menu to the left of the button, slightly below
menuPosition.value = {
top: rect.bottom + 4,
left: rect.right - 208 // w-52 is 208px
}
}
activeMenuId.value = account.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()
}
}
// Bulk selection // Bulk selection
const selectedAccountIds = ref<number[]>([]) const selectedAccountIds = ref<number[]>([])
const selectCurrentPageAccounts = () => { const selectCurrentPageAccounts = () => {
@@ -669,6 +663,9 @@ const isOverloaded = (account: Account): boolean => {
// Data loading // Data loading
const loadAccounts = async () => { const loadAccounts = async () => {
abortController?.abort()
const currentAbortController = new AbortController()
abortController = currentAbortController
loading.value = true loading.value = true
try { try {
const response = await adminAPI.accounts.list(pagination.page, pagination.page_size, { const response = await adminAPI.accounts.list(pagination.page, pagination.page_size, {
@@ -676,16 +673,25 @@ const loadAccounts = async () => {
type: filters.type || undefined, type: filters.type || undefined,
status: filters.status || undefined, status: filters.status || undefined,
search: searchQuery.value || undefined search: searchQuery.value || undefined
}, {
signal: currentAbortController.signal
}) })
if (currentAbortController.signal.aborted) return
accounts.value = response.items accounts.value = response.items
pagination.total = response.total pagination.total = response.total
pagination.pages = response.pages pagination.pages = response.pages
} catch (error) { } 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.accounts.failedToLoad')) appStore.showError(t('admin.accounts.failedToLoad'))
console.error('Error loading accounts:', error) console.error('Error loading accounts:', error)
} finally { } finally {
if (abortController === currentAbortController) {
loading.value = false loading.value = false
} }
}
} }
const loadProxies = async () => { const loadProxies = async () => {
@@ -721,6 +727,12 @@ const handlePageChange = (page: number) => {
loadAccounts() loadAccounts()
} }
const handlePageSizeChange = (pageSize: number) => {
pagination.page_size = pageSize
pagination.page = 1
loadAccounts()
}
const handleCrsSynced = () => { const handleCrsSynced = () => {
showCrsSyncModal.value = false showCrsSyncModal.value = false
loadAccounts() loadAccounts()
@@ -910,5 +922,12 @@ onMounted(() => {
loadAccounts() loadAccounts()
loadProxies() loadProxies()
loadGroups() loadGroups()
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
abortController?.abort()
abortController = null
document.removeEventListener('click', handleClickOutside)
}) })
</script> </script>

View File

@@ -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
@@ -345,11 +346,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 +382,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" />
@@ -490,11 +504,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 +540,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 +568,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 +638,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,22 +684,34 @@ 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 {
if (abortController === currentController && !signal.aborted) {
loading.value = false loading.value = false
} }
}
} }
const handlePageChange = (page: number) => { const handlePageChange = (page: number) => {
@@ -683,6 +719,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 = ''

View File

@@ -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 @@
</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 @@
/> />
</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 @@
</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 @@
}} }}
</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 @@
<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 @@
{{ 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'
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({
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 {
if (abortController === currentAbortController) {
loading.value = false loading.value = false
abortController = null
}
} }
} }
@@ -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'

View File

@@ -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({
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({
}) })
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(
pagination.page,
pagination.page_size,
{
type: filters.type as RedeemCodeType, type: filters.type as RedeemCodeType,
status: filters.status as any, status: filters.status as any,
search: searchQuery.value || undefined 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 {
if (abortController === currentController && !currentController.signal.aborted) {
loading.value = false loading.value = false
abortController = null
}
} }
} }
@@ -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) {

View File

@@ -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 @@
<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 @@
{{ 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 @@
<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'
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[]>([])
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(() =>
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 {
if (abortController === requestController) {
loading.value = false loading.value = false
abortController = null
}
} }
} }
@@ -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

View File

@@ -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 @@
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 @@
: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[]>([])
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)
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') },
@@ -796,7 +799,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 +810,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 +867,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,18 +893,24 @@ 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 {
if (!signal.aborted && abortController === controller) {
loading.value = false loading.value = false
} }
}
} }
const loadUsageStats = async () => { const loadUsageStats = async () => {
@@ -944,27 +971,37 @@ 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 endDate = new Date()
const startDateRange = new Date(endDate)
startDateRange.setDate(startDateRange.getDate() - 29)
const response = await adminAPI.dashboard.getModelStats({
start_date: startDateRange.toISOString().split('T')[0],
end_date: endDate.toISOString().split('T')[0]
})
const uniqueModels = new Set<string>() const uniqueModels = new Set<string>()
usageLogs.value.forEach(log => { response.models?.forEach((stat) => {
if (log.model) { if (stat.model) {
uniqueModels.add(log.model) uniqueModels.add(stat.model)
} }
}) })
models.value = Array.from(uniqueModels).sort() models.value = Array.from(uniqueModels).sort()
} catch (error) {
console.error('Failed to load model options:', error)
}
} }
const resetFilters = () => { const resetFilters = () => {
@@ -987,6 +1024,7 @@ const resetFilters = () => {
// Reset date range to default (last 7 days) // Reset date range to default (last 7 days)
initializeDateRange() initializeDateRange()
pagination.value.page = 1 pagination.value.page = 1
loadApiKeys()
loadUsageLogs() loadUsageLogs()
loadUsageStats() loadUsageStats()
loadChartData() loadChartData()
@@ -997,6 +1035,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'))
@@ -1072,6 +1116,7 @@ const hideTooltip = () => {
onMounted(() => { onMounted(() => {
initializeDateRange() initializeDateRange()
loadFilterOptions() loadFilterOptions()
loadApiKeys()
loadUsageLogs() loadUsageLogs()
loadUsageStats() loadUsageStats()
loadChartData() loadChartData()
@@ -1083,5 +1128,8 @@ onUnmounted(() => {
if (searchTimeout) { if (searchTimeout) {
clearTimeout(searchTimeout) clearTimeout(searchTimeout)
} }
if (abortController) {
abortController.abort()
}
}) })
</script> </script>

View File

@@ -198,12 +198,13 @@
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDateTime(value) }}</span> <span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDateTime(value) }}</span>
</template> </template>
<template #cell-actions="{ row, expanded }"> <template #cell-actions="{ row }">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<!-- 主要操作编辑和删除始终显示 --> <!-- Edit Button -->
<button <button
@click="handleEdit(row)" @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 <svg
class="h-4 w-4" 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" 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> </svg>
<span class="text-xs">{{ t('common.edit') }}</span>
</button>
<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"
>
<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="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> </button>
<!-- 次要操作展开时显示 --> <!-- More Actions Menu Trigger -->
<template v-if="expanded">
<!-- Toggle Status (hidden for admin users) -->
<button <button
v-if="row.role !== 'admin'" :ref="(el) => setActionButtonRef(row.id, el)"
@click="handleToggleStatus(row)" @click="openActionMenu(row)"
:class="[ 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"
'flex flex-col items-center gap-0.5 rounded-lg p-1.5 transition-colors', :class="{ 'bg-gray-100 text-gray-900 dark:bg-dark-700 dark:text-white': activeMenuId === row.id }"
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 <svg
v-if="row.status === 'active'" class="h-5 w-5"
class="h-4 w-4"
fill="none" fill="none"
stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5" stroke-width="1.5"
> >
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="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>
<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> </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>
</div> </div>
</template> </template>
@@ -379,18 +264,121 @@
: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>
<!-- 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 --> <!-- Create User Modal -->
<Modal <BaseDialog
:show="showCreateModal" :show="showCreateModal"
:title="t('admin.users.createUser')" :title="t('admin.users.createUser')"
size="lg" width="normal"
@close="closeCreateModal" @close="closeCreateModal"
> >
<form @submit.prevent="handleCreateUser" class="space-y-5"> <form id="create-user-form" @submit.prevent="handleCreateUser" class="space-y-5">
<div> <div>
<label class="input-label">{{ t('admin.users.email') }}</label> <label class="input-label">{{ t('admin.users.email') }}</label>
<input <input
@@ -512,12 +500,19 @@
<input v-model.number="createForm.concurrency" type="number" class="input" /> <input v-model.number="createForm.concurrency" type="number" class="input" />
</div> </div>
</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"> <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-user-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"
@@ -541,17 +536,22 @@
{{ submitting ? t('admin.users.creating') : t('common.create') }} {{ submitting ? t('admin.users.creating') : t('common.create') }}
</button> </button>
</div> </div>
</form> </template>
</Modal> </BaseDialog>
<!-- Edit User Modal --> <!-- Edit User Modal -->
<Modal <BaseDialog
:show="showEditModal" :show="showEditModal"
:title="t('admin.users.editUser')" :title="t('admin.users.editUser')"
size="lg" width="normal"
@close="closeEditModal" @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> <div>
<label class="input-label">{{ t('admin.users.email') }}</label> <label class="input-label">{{ t('admin.users.email') }}</label>
<input v-model="editForm.email" type="email" class="input" /> <input v-model="editForm.email" type="email" class="input" />
@@ -664,11 +664,19 @@
<input v-model.number="editForm.concurrency" type="number" class="input" /> <input v-model.number="editForm.concurrency" type="number" class="input" />
</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
type="submit"
form="edit-user-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"
@@ -692,14 +700,14 @@
{{ submitting ? t('admin.users.updating') : t('common.update') }} {{ submitting ? t('admin.users.updating') : t('common.update') }}
</button> </button>
</div> </div>
</form> </template>
</Modal> </BaseDialog>
<!-- View API Keys Modal --> <!-- View API Keys Modal -->
<Modal <BaseDialog
:show="showApiKeysModal" :show="showApiKeysModal"
:title="t('admin.users.userApiKeys')" :title="t('admin.users.userApiKeys')"
size="xl" width="wide"
@close="closeApiKeysModal" @close="closeApiKeysModal"
> >
<div v-if="viewingUser" class="space-y-4"> <div v-if="viewingUser" class="space-y-4">
@@ -828,13 +836,13 @@
</button> </button>
</div> </div>
</template> </template>
</Modal> </BaseDialog>
<!-- Allowed Groups Modal --> <!-- Allowed Groups Modal -->
<Modal <BaseDialog
:show="showAllowedGroupsModal" :show="showAllowedGroupsModal"
:title="t('admin.users.setAllowedGroups')" :title="t('admin.users.setAllowedGroups')"
size="lg" width="normal"
@close="closeAllowedGroupsModal" @close="closeAllowedGroupsModal"
> >
<div v-if="allowedGroupsUser" class="space-y-4"> <div v-if="allowedGroupsUser" class="space-y-4">
@@ -994,16 +1002,21 @@
</button> </button>
</div> </div>
</template> </template>
</Modal> </BaseDialog>
<!-- Deposit/Withdraw Modal --> <!-- Deposit/Withdraw Modal -->
<Modal <BaseDialog
:show="showBalanceModal" :show="showBalanceModal"
:title="balanceOperation === 'add' ? t('admin.users.deposit') : t('admin.users.withdraw')" :title="balanceOperation === 'add' ? t('admin.users.deposit') : t('admin.users.withdraw')"
size="md" width="narrow"
@close="closeBalanceModal" @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 items-center gap-3 rounded-xl bg-gray-50 p-4 dark:bg-dark-700">
<div <div
class="flex h-10 w-10 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30" 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> </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"> <button @click="closeBalanceModal" type="button" class="btn btn-secondary">
{{ t('common.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button <button
type="submit" type="submit"
form="balance-form"
:disabled=" :disabled="
balanceSubmitting || balanceSubmitting ||
!balanceForm.amount || !balanceForm.amount ||
@@ -1148,8 +1165,8 @@
}} }}
</button> </button>
</div> </div>
</form> </template>
</Modal> </BaseDialog>
<!-- Delete Confirmation Dialog --> <!-- Delete Confirmation Dialog -->
<ConfirmDialog <ConfirmDialog
@@ -1166,7 +1183,7 @@
</template> </template>
<script setup lang="ts"> <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 { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useClipboard } from '@/composables/useClipboard' import { useClipboard } from '@/composables/useClipboard'
@@ -1181,7 +1198,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'
@@ -1244,6 +1261,63 @@ const viewingUser = ref<User | null>(null)
const userApiKeys = ref<ApiKey[]>([]) const userApiKeys = ref<ApiKey[]>([])
const loadingApiKeys = ref(false) const loadingApiKeys = ref(false)
const passwordCopied = 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 // Allowed groups modal state
const showAllowedGroupsModal = ref(false) const showAllowedGroupsModal = ref(false)
@@ -1331,13 +1405,25 @@ const copyEditPassword = async () => {
} }
const loadUsers = async () => { const loadUsers = async () => {
abortController?.abort()
const currentAbortController = new AbortController()
abortController = currentAbortController
const { signal } = currentAbortController
loading.value = true loading.value = true
try { try {
const response = await adminAPI.users.list(pagination.page, pagination.page_size, { const response = await adminAPI.users.list(
pagination.page,
pagination.page_size,
{
role: filters.role as any, role: filters.role as any,
status: filters.status as any, status: filters.status as any,
search: searchQuery.value || undefined search: searchQuery.value || undefined
}) },
{ signal }
)
if (signal.aborted) {
return
}
users.value = response.items users.value = response.items
pagination.total = response.total pagination.total = response.total
pagination.pages = response.pages pagination.pages = response.pages
@@ -1347,17 +1433,29 @@ const loadUsers = async () => {
const userIds = response.items.map((u) => u.id) const userIds = response.items.map((u) => u.id)
try { try {
const usageResponse = await adminAPI.dashboard.getBatchUsersUsage(userIds) const usageResponse = await adminAPI.dashboard.getBatchUsersUsage(userIds)
if (signal.aborted) {
return
}
usageStats.value = usageResponse.stats usageStats.value = usageResponse.stats
} catch (e) { } catch (e) {
if (signal.aborted) {
return
}
console.error('Failed to load usage stats:', e) console.error('Failed to load usage stats:', e)
} }
} }
} catch (error) { } 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')) appStore.showError(t('admin.users.failedToLoad'))
console.error('Error loading users:', error) console.error('Error loading users:', error)
} finally { } finally {
if (abortController === currentAbortController) {
loading.value = false loading.value = false
} }
}
} }
let searchTimeout: ReturnType<typeof setTimeout> let searchTimeout: ReturnType<typeof setTimeout>
@@ -1374,6 +1472,12 @@ const handlePageChange = (page: number) => {
loadUsers() loadUsers()
} }
const handlePageSizeChange = (pageSize: number) => {
pagination.page_size = pageSize
pagination.page = 1
loadUsers()
}
const closeCreateModal = () => { const closeCreateModal = () => {
showCreateModal.value = false showCreateModal.value = false
createForm.email = '' createForm.email = ''
@@ -1620,5 +1724,10 @@ const handleBalanceSubmit = async () => {
onMounted(() => { onMounted(() => {
loadUsers() loadUsers()
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
}) })
</script> </script>

View File

@@ -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"

View File

@@ -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"

View File

@@ -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,29 +674,23 @@ 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', {
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 needs_setup is false, service has restarted in normal mode
if (data.data && !data.data.needs_setup) { if (data.data && !data.data.needs_setup) {
serviceReady.value = true serviceReady.value = true
@@ -707,9 +701,8 @@ async function waitForServiceRestart() {
return 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))

View File

@@ -322,7 +322,13 @@
<!-- Charts Grid --> <!-- Charts Grid -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2"> <div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- Model Distribution Chart --> <!-- 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"> <h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
{{ t('dashboard.modelDistribution') }} {{ t('dashboard.modelDistribution') }}
</h3> </h3>
@@ -330,6 +336,7 @@
<div class="h-48 w-48"> <div class="h-48 w-48">
<Doughnut <Doughnut
v-if="modelChartData" v-if="modelChartData"
ref="modelChartRef"
:data="modelChartData" :data="modelChartData"
:options="doughnutOptions" :options="doughnutOptions"
/> />
@@ -383,12 +390,23 @@
</div> </div>
<!-- Token Usage Trend Chart --> <!-- 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"> <h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
{{ t('dashboard.tokenUsageTrend') }} {{ t('dashboard.tokenUsageTrend') }}
</h3> </h3>
<div class="h-48"> <div class="h-48">
<Line v-if="trendChartData" :data="trendChartData" :options="lineOptions" /> <Line
v-if="trendChartData"
ref="trendChartRef"
:data="trendChartData"
:options="lineOptions"
/>
<div <div
v-else v-else
class="flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400" class="flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400"
@@ -645,10 +663,11 @@
</template> </template>
<script setup lang="ts"> <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 { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useSubscriptionStore } from '@/stores/subscriptions'
import { formatDateTime } from '@/utils/format' import { formatDateTime } from '@/utils/format'
const { t } = useI18n() const { t } = useI18n()
@@ -689,15 +708,21 @@ ChartJS.register(
const router = useRouter() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
const subscriptionStore = useSubscriptionStore()
const user = computed(() => authStore.user) const user = computed(() => authStore.user)
const stats = ref<UserDashboardStats | null>(null) const stats = ref<UserDashboardStats | null>(null)
const loading = ref(false) const loading = ref(false)
const loadingUsage = ref(false) const loadingUsage = ref(false)
const loadingCharts = ref(false)
type ChartComponentRef = { chart?: ChartJS }
// Chart data // Chart data
const trendData = ref<TrendDataPoint[]>([]) const trendData = ref<TrendDataPoint[]>([])
const modelStats = ref<ModelStat[]>([]) const modelStats = ref<ModelStat[]>([])
const modelChartRef = ref<ChartComponentRef | null>(null)
const trendChartRef = ref<ChartComponentRef | null>(null)
// Recent usage // Recent usage
const recentUsage = ref<UsageLog[]>([]) const recentUsage = ref<UsageLog[]>([])
@@ -964,6 +989,7 @@ const loadDashboardStats = async () => {
} }
const loadChartData = async () => { const loadChartData = async () => {
loadingCharts.value = true
try { try {
const params = { const params = {
start_date: startDate.value, start_date: startDate.value,
@@ -981,14 +1007,16 @@ const loadChartData = async () => {
modelStats.value = modelResponse.models || [] modelStats.value = modelResponse.models || []
} catch (error) { } catch (error) {
console.error('Error loading chart data:', error) console.error('Error loading chart data:', error)
} finally {
loadingCharts.value = false
} }
} }
const loadRecentUsage = async () => { const loadRecentUsage = async () => {
loadingUsage.value = true loadingUsage.value = true
try { try {
const endDate = new Date().toISOString() const endDate = new Date().toISOString().split('T')[0]
const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString() const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
const usageResponse = await usageAPI.getByDateRange(startDate, endDate) const usageResponse = await usageAPI.getByDateRange(startDate, endDate)
recentUsage.value = usageResponse.items.slice(0, 5) recentUsage.value = usageResponse.items.slice(0, 5)
} catch (error) { } catch (error) {
@@ -998,16 +1026,30 @@ const loadRecentUsage = async () => {
} }
} }
onMounted(() => { onMounted(async () => {
loadDashboardStats() // 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() 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 for dark mode changes
watch(isDarkMode, () => { watch(isDarkMode, () => {
// Force chart re-render on theme change nextTick(() => {
modelChartRef.value?.chart?.update()
trendChartRef.value?.chart?.update()
})
}) })
</script> </script>

View File

@@ -292,17 +292,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/Edit Modal --> <!-- Create/Edit Modal -->
<Modal <BaseDialog
:show="showCreateModal || showEditModal" :show="showCreateModal || showEditModal"
:title="showEditModal ? t('keys.editKey') : t('keys.createKey')" :title="showEditModal ? t('keys.editKey') : t('keys.createKey')"
width="narrow"
@close="closeModals" @close="closeModals"
> >
<form @submit.prevent="handleSubmit" class="space-y-5"> <form id="key-form" @submit.prevent="handleSubmit" class="space-y-5">
<div> <div>
<label class="input-label">{{ t('keys.nameLabel') }}</label> <label class="input-label">{{ t('keys.nameLabel') }}</label>
<input <input
@@ -383,12 +385,13 @@
:placeholder="t('keys.selectStatus')" :placeholder="t('keys.selectStatus')"
/> />
</div> </div>
</form>
<div class="flex justify-end gap-3 pt-4"> <template #footer>
<div class="flex justify-end gap-3">
<button @click="closeModals" type="button" class="btn btn-secondary"> <button @click="closeModals" type="button" class="btn btn-secondary">
{{ t('common.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button type="submit" :disabled="submitting" class="btn btn-primary"> <button form="key-form" type="submit" :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"
@@ -418,8 +421,8 @@
}} }}
</button> </button>
</div> </div>
</form> </template>
</Modal> </BaseDialog>
<!-- Delete Confirmation Dialog --> <!-- Delete Confirmation Dialog -->
<ConfirmDialog <ConfirmDialog
@@ -501,7 +504,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'
@@ -557,6 +560,7 @@ const publicSettings = ref<PublicSettings | null>(null)
const dropdownRef = ref<HTMLElement | null>(null) const dropdownRef = ref<HTMLElement | null>(null)
const dropdownPosition = ref<{ top: number; left: number } | null>(null) const dropdownPosition = ref<{ top: number; left: number } | null>(null)
const groupButtonRefs = ref<Map<number, HTMLElement>>(new Map()) const groupButtonRefs = ref<Map<number, HTMLElement>>(new Map())
let abortController: AbortController | null = null
// Get the currently selected key for group change // Get the currently selected key for group change
const selectedKeyForGroup = computed(() => { const selectedKeyForGroup = computed(() => {
@@ -623,14 +627,27 @@ const copyToClipboard = async (text: string, keyId: number) => {
copiedKeyId.value = keyId copiedKeyId.value = keyId
setTimeout(() => { setTimeout(() => {
copiedKeyId.value = null 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 () => { const loadApiKeys = async () => {
abortController?.abort()
const controller = new AbortController()
abortController = controller
const { signal } = controller
loading.value = true loading.value = true
try { 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 apiKeys.value = response.items
pagination.value.total = response.total pagination.value.total = response.total
pagination.value.pages = response.pages pagination.value.pages = response.pages
@@ -639,17 +656,25 @@ const loadApiKeys = async () => {
if (response.items.length > 0) { if (response.items.length > 0) {
const keyIds = response.items.map((k) => k.id) const keyIds = response.items.map((k) => k.id)
try { try {
const usageResponse = await usageAPI.getDashboardApiKeysUsage(keyIds) const usageResponse = await usageAPI.getDashboardApiKeysUsage(keyIds, { signal })
if (signal.aborted) return
usageStats.value = usageResponse.stats usageStats.value = usageResponse.stats
} catch (e) { } catch (e) {
if (!isAbortError(e)) {
console.error('Failed to load usage stats:', e) console.error('Failed to load usage stats:', e)
} }
} }
}
} catch (error) { } catch (error) {
if (isAbortError(error)) {
return
}
appStore.showError(t('keys.failedToLoad')) appStore.showError(t('keys.failedToLoad'))
} finally { } finally {
if (abortController === controller) {
loading.value = false loading.value = false
} }
}
} }
const loadGroups = async () => { const loadGroups = async () => {
@@ -683,6 +708,12 @@ const handlePageChange = (page: number) => {
loadApiKeys() loadApiKeys()
} }
const handlePageSizeChange = (pageSize: number) => {
pagination.value.page_size = pageSize
pagination.value.page = 1
loadApiKeys()
}
const editKey = (key: ApiKey) => { const editKey = (key: ApiKey) => {
selectedKey.value = key selectedKey.value = key
formData.value = { formData.value = {

View File

@@ -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 () => {
} }
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({

View File

@@ -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'
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 () => {
// 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 = ''

View File

@@ -164,8 +164,28 @@
<button @click="resetFilters" class="btn btn-secondary"> <button @click="resetFilters" class="btn btn-secondary">
{{ t('common.reset') }} {{ t('common.reset') }}
</button> </button>
<button @click="exportToCSV" class="btn btn-primary"> <button @click="exportToCSV" :disabled="exporting" class="btn btn-primary">
{{ t('usage.exportCsv') }} <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> </button>
</div> </div>
</div> </div>
@@ -366,6 +386,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"
/> />
</template> </template>
</TablePageLayout> </TablePageLayout>
@@ -412,7 +433,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, reactive, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { usageAPI, keysAPI } from '@/api' import { usageAPI, keysAPI } from '@/api'
@@ -430,6 +451,8 @@ import { formatDateTime } from '@/utils/format'
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
let abortController: AbortController | null = null
// Tooltip state // Tooltip state
const tooltipVisible = ref(false) const tooltipVisible = ref(false)
const tooltipPosition = ref({ x: 0, y: 0 }) const tooltipPosition = ref({ x: 0, y: 0 })
@@ -453,6 +476,7 @@ const columns = computed<Column[]>(() => [
const usageLogs = ref<UsageLog[]>([]) const usageLogs = ref<UsageLog[]>([])
const apiKeys = ref<ApiKey[]>([]) const apiKeys = ref<ApiKey[]>([])
const loading = ref(false) const loading = ref(false)
const exporting = ref(false)
const apiKeyOptions = computed(() => { const apiKeyOptions = computed(() => {
return [ return [
@@ -498,7 +522,7 @@ const onDateRangeChange = (range: {
applyFilters() applyFilters()
} }
const pagination = ref({ const pagination = reactive({
page: 1, page: 1,
page_size: 20, page_size: 20,
total: 0, total: 0,
@@ -532,23 +556,41 @@ const formatCacheTokens = (value: number): string => {
} }
const loadUsageLogs = async () => { const loadUsageLogs = async () => {
if (abortController) {
abortController.abort()
}
const currentAbortController = new AbortController()
abortController = currentAbortController
const { signal } = currentAbortController
loading.value = true loading.value = true
try { try {
const params: UsageQueryParams = { const params: UsageQueryParams = {
page: pagination.value.page, page: pagination.page,
page_size: pagination.value.page_size, page_size: pagination.page_size,
...filters.value ...filters.value
} }
const response = await usageAPI.query(params) const response = await usageAPI.query(params, { signal })
if (signal.aborted) {
return
}
usageLogs.value = response.items usageLogs.value = response.items
pagination.value.total = response.total pagination.total = response.total
pagination.value.pages = response.pages pagination.pages = response.pages
} catch (error) { } 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')) appStore.showError(t('usage.failedToLoad'))
} finally { } finally {
if (abortController === currentAbortController) {
loading.value = false loading.value = false
} }
}
} }
const loadApiKeys = async () => { const loadApiKeys = async () => {
@@ -575,7 +617,7 @@ const loadUsageStats = async () => {
} }
const applyFilters = () => { const applyFilters = () => {
pagination.value.page = 1 pagination.page = 1
loadUsageLogs() loadUsageLogs()
loadUsageStats() loadUsageStats()
} }
@@ -588,60 +630,128 @@ const resetFilters = () => {
} }
// Reset date range to default (last 7 days) // Reset date range to default (last 7 days)
initializeDateRange() initializeDateRange()
pagination.value.page = 1 pagination.page = 1
loadUsageLogs() loadUsageLogs()
loadUsageStats() loadUsageStats()
} }
const handlePageChange = (page: number) => { const handlePageChange = (page: number) => {
pagination.value.page = page pagination.page = page
loadUsageLogs() loadUsageLogs()
} }
const exportToCSV = () => { const handlePageSizeChange = (pageSize: number) => {
if (usageLogs.value.length === 0) { 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
}
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')) appStore.showWarning(t('usage.noDataToExport'))
return return
} }
const headers = [ const headers = [
'Time',
'API Key Name',
'Model', 'Model',
'Type', 'Type',
'Input Tokens', 'Input Tokens',
'Output Tokens', 'Output Tokens',
'Cache Read Tokens', 'Cache Read Tokens',
'Cache Write Tokens', 'Cache Creation Tokens',
'Total Cost', 'Rate Multiplier',
'Billed Cost',
'Original Cost',
'Billing Type', 'Billing Type',
'First Token (ms)', 'First Token (ms)',
'Duration (ms)', 'Duration (ms)'
'Time'
] ]
const rows = usageLogs.value.map((log) => [ const rows = allLogs.map((log) =>
[
log.created_at,
log.api_key?.name || '',
log.model, log.model,
log.stream ? 'Stream' : 'Sync', log.stream ? 'Stream' : 'Sync',
log.input_tokens, log.input_tokens,
log.output_tokens, log.output_tokens,
log.cache_read_tokens, log.cache_read_tokens,
log.cache_creation_tokens, log.cache_creation_tokens,
log.total_cost.toFixed(6), log.rate_multiplier,
log.actual_cost.toFixed(8),
log.total_cost.toFixed(8),
log.billing_type === 1 ? 'Subscription' : 'Balance', log.billing_type === 1 ? 'Subscription' : 'Balance',
log.first_token_ms ?? '', log.first_token_ms ?? '',
log.duration_ms, log.duration_ms
log.created_at ].map(escapeCSVValue)
]) )
const csvContent = [headers.join(','), ...rows.map((row) => row.join(','))].join('\n') const csvContent = [
headers.map(escapeCSVValue).join(','),
...rows.map((row) => row.join(','))
].join('\n')
const blob = new Blob([csvContent], { type: 'text/csv' }) const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const url = window.URL.createObjectURL(blob) const url = window.URL.createObjectURL(blob)
const link = document.createElement('a') const link = document.createElement('a')
link.href = url link.href = url
link.download = `usage_${new Date().toISOString().split('T')[0]}.csv` link.download = `usage_${filters.value.start_date}_to_${filters.value.end_date}.csv`
link.click() link.click()
window.URL.revokeObjectURL(url) window.URL.revokeObjectURL(url)
appStore.showSuccess(t('usage.exportSuccess')) appStore.showSuccess(t('usage.exportSuccess'))
} catch (error) {
appStore.showError(t('usage.exportFailed'))
console.error('CSV Export failed:', error)
} finally {
exporting.value = false
}
} }
// Tooltip functions // Tooltip functions