Merge branch 'main' into feature/antigravity_auth
This commit is contained in:
@@ -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},
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
118
frontend/src/components/common/BaseDialog.vue
Normal file
118
frontend/src/components/common/BaseDialog.vue
Normal 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>
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,13 +190,37 @@ 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) {
|
||||||
nextTick(() => {
|
calculateDropdownPosition()
|
||||||
searchInputRef.value?.focus()
|
if (props.searchable) {
|
||||||
})
|
nextTick(() => {
|
||||||
|
searchInputRef.value?.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: '新密码',
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
140
frontend/src/stores/subscriptions.ts
Normal file
140
frontend/src/stores/subscriptions.ts
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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];
|
||||||
|
|||||||
@@ -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">
|
<button
|
||||||
<!-- Reset Status button for error accounts -->
|
:ref="(el) => setActionButtonRef(row.id, el)"
|
||||||
<button
|
@click="openActionMenu(row)"
|
||||||
v-if="row.status === 'error'"
|
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"
|
||||||
@click="handleResetStatus(row)"
|
:class="{ 'bg-gray-100 text-gray-900 dark:bg-dark-700 dark:text-white': activeMenuId === row.id }"
|
||||||
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"
|
>
|
||||||
>
|
|
||||||
<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,15 +673,24 @@ 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 {
|
||||||
loading.value = false
|
if (abortController === currentAbortController) {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,21 +684,33 @@ const deleteConfirmMessage = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const loadGroups = async () => {
|
const loadGroups = async () => {
|
||||||
|
if (abortController) {
|
||||||
|
abortController.abort()
|
||||||
|
}
|
||||||
|
const currentController = new AbortController()
|
||||||
|
abortController = currentController
|
||||||
|
const { signal } = currentController
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await adminAPI.groups.list(pagination.page, pagination.page_size, {
|
const response = await adminAPI.groups.list(pagination.page, pagination.page_size, {
|
||||||
platform: (filters.platform as GroupPlatform) || undefined,
|
platform: (filters.platform as GroupPlatform) || undefined,
|
||||||
status: filters.status as any,
|
status: filters.status as any,
|
||||||
is_exclusive: filters.is_exclusive ? filters.is_exclusive === 'true' : undefined
|
is_exclusive: filters.is_exclusive ? filters.is_exclusive === 'true' : undefined
|
||||||
})
|
}, { signal })
|
||||||
|
if (signal.aborted) return
|
||||||
groups.value = response.items
|
groups.value = response.items
|
||||||
pagination.total = response.total
|
pagination.total = response.total
|
||||||
pagination.pages = response.pages
|
pagination.pages = response.pages
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
|
if (signal.aborted || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') {
|
||||||
|
return
|
||||||
|
}
|
||||||
appStore.showError(t('admin.groups.failedToLoad'))
|
appStore.showError(t('admin.groups.failedToLoad'))
|
||||||
console.error('Error loading groups:', error)
|
console.error('Error loading groups:', error)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (abortController === currentController && !signal.aborted) {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -683,6 +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 = ''
|
||||||
|
|||||||
@@ -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 {
|
||||||
loading.value = false
|
if (abortController === currentAbortController) {
|
||||||
|
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'
|
||||||
|
|||||||
@@ -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(
|
||||||
type: filters.type as RedeemCodeType,
|
pagination.page,
|
||||||
status: filters.status as any,
|
pagination.page_size,
|
||||||
search: searchQuery.value || undefined
|
{
|
||||||
})
|
type: filters.type as RedeemCodeType,
|
||||||
|
status: filters.status as any,
|
||||||
|
search: searchQuery.value || undefined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
signal: currentController.signal
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (currentController.signal.aborted) {
|
||||||
|
return
|
||||||
|
}
|
||||||
codes.value = response.items
|
codes.value = response.items
|
||||||
pagination.total = response.total
|
pagination.total = response.total
|
||||||
pagination.pages = response.pages
|
pagination.pages = response.pages
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
|
if (
|
||||||
|
currentController.signal.aborted ||
|
||||||
|
error?.name === 'AbortError' ||
|
||||||
|
error?.code === 'ERR_CANCELED'
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
appStore.showError(t('admin.redeem.failedToLoad'))
|
appStore.showError(t('admin.redeem.failedToLoad'))
|
||||||
console.error('Error loading redeem codes:', error)
|
console.error('Error loading redeem codes:', error)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (abortController === currentController && !currentController.signal.aborted) {
|
||||||
|
loading.value = false
|
||||||
|
abortController = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -588,6 +616,12 @@ const handlePageChange = (page: number) => {
|
|||||||
loadCodes()
|
loadCodes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handlePageSizeChange = (pageSize: number) => {
|
||||||
|
pagination.page_size = pageSize
|
||||||
|
pagination.page = 1
|
||||||
|
loadCodes()
|
||||||
|
}
|
||||||
|
|
||||||
const handleGenerateCodes = async () => {
|
const handleGenerateCodes = async () => {
|
||||||
// 订阅类型必须选择分组
|
// 订阅类型必须选择分组
|
||||||
if (generateForm.type === 'subscription' && !generateForm.group_id) {
|
if (generateForm.type === 'subscription' && !generateForm.group_id) {
|
||||||
|
|||||||
@@ -316,18 +316,23 @@
|
|||||||
: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 {
|
||||||
loading.value = false
|
if (abortController === requestController) {
|
||||||
|
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
|
||||||
|
|||||||
@@ -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,17 +893,23 @@ const loadUsageLogs = async () => {
|
|||||||
...filters.value
|
...filters.value
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await adminAPI.usage.list(params)
|
const response = await adminAPI.usage.list(params, { signal })
|
||||||
|
if (signal.aborted) {
|
||||||
|
return
|
||||||
|
}
|
||||||
usageLogs.value = response.items
|
usageLogs.value = response.items
|
||||||
pagination.value.total = response.total
|
pagination.value.total = response.total
|
||||||
pagination.value.pages = response.pages
|
pagination.value.pages = response.pages
|
||||||
|
|
||||||
// Extract models from loaded logs for filter options
|
|
||||||
extractModelsFromLogs()
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (signal.aborted || isAbortError(error)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
appStore.showError(t('usage.failedToLoad'))
|
appStore.showError(t('usage.failedToLoad'))
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (!signal.aborted && abortController === controller) {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -944,27 +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 uniqueModels = new Set<string>()
|
const endDate = new Date()
|
||||||
usageLogs.value.forEach(log => {
|
const startDateRange = new Date(endDate)
|
||||||
if (log.model) {
|
startDateRange.setDate(startDateRange.getDate() - 29)
|
||||||
uniqueModels.add(log.model)
|
const response = await adminAPI.dashboard.getModelStats({
|
||||||
}
|
start_date: startDateRange.toISOString().split('T')[0],
|
||||||
})
|
end_date: endDate.toISOString().split('T')[0]
|
||||||
models.value = Array.from(uniqueModels).sort()
|
})
|
||||||
|
const uniqueModels = new Set<string>()
|
||||||
|
response.models?.forEach((stat) => {
|
||||||
|
if (stat.model) {
|
||||||
|
uniqueModels.add(stat.model)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
models.value = Array.from(uniqueModels).sort()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load model options:', error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
<button
|
||||||
<!-- Toggle Status (hidden for admin users) -->
|
:ref="(el) => setActionButtonRef(row.id, el)"
|
||||||
<button
|
@click="openActionMenu(row)"
|
||||||
v-if="row.role !== 'admin'"
|
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"
|
||||||
@click="handleToggleStatus(row)"
|
:class="{ 'bg-gray-100 text-gray-900 dark:bg-dark-700 dark:text-white': activeMenuId === row.id }"
|
||||||
:class="[
|
>
|
||||||
'flex flex-col items-center gap-0.5 rounded-lg p-1.5 transition-colors',
|
|
||||||
row.status === 'active'
|
|
||||||
? 'text-gray-500 hover:bg-orange-50 hover:text-orange-600 dark:hover:bg-orange-900/20 dark:hover:text-orange-400'
|
|
||||||
: 'text-gray-500 hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<svg
|
<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
|
</button>
|
||||||
v-else
|
|
||||||
class="h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="1.5"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span class="text-xs">{{ row.status === 'active' ? t('admin.users.disable') : t('admin.users.enable') }}</span>
|
|
||||||
</button>
|
|
||||||
<!-- Allowed Groups -->
|
|
||||||
<button
|
|
||||||
@click="handleAllowedGroups(row)"
|
|
||||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="1.5"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span class="text-xs">{{ t('admin.users.groups') }}</span>
|
|
||||||
</button>
|
|
||||||
<!-- View API Keys -->
|
|
||||||
<button
|
|
||||||
@click="handleViewApiKeys(row)"
|
|
||||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-purple-50 hover:text-purple-600 dark:hover:bg-purple-900/20 dark:hover:text-purple-400"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="1.5"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span class="text-xs">{{ t('admin.users.apiKeys') }}</span>
|
|
||||||
</button>
|
|
||||||
<!-- Deposit -->
|
|
||||||
<button
|
|
||||||
@click="handleDeposit(row)"
|
|
||||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-emerald-50 hover:text-emerald-600 dark:hover:bg-emerald-900/20 dark:hover:text-emerald-400"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="1.5"
|
|
||||||
>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
|
||||||
</svg>
|
|
||||||
<span class="text-xs">{{ t('admin.users.deposit') }}</span>
|
|
||||||
</button>
|
|
||||||
<!-- Withdraw -->
|
|
||||||
<button
|
|
||||||
@click="handleWithdraw(row)"
|
|
||||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="1.5"
|
|
||||||
>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14" />
|
|
||||||
</svg>
|
|
||||||
<span class="text-xs">{{ t('admin.users.withdraw') }}</span>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</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(
|
||||||
role: filters.role as any,
|
pagination.page,
|
||||||
status: filters.status as any,
|
pagination.page_size,
|
||||||
search: searchQuery.value || undefined
|
{
|
||||||
})
|
role: filters.role as any,
|
||||||
|
status: filters.status as any,
|
||||||
|
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,16 +1433,28 @@ 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 {
|
||||||
loading.value = false
|
if (abortController === currentAbortController) {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
v-model="formData.email"
|
v-model="formData.email"
|
||||||
type="email"
|
type="email"
|
||||||
required
|
required
|
||||||
|
autofocus
|
||||||
autocomplete="email"
|
autocomplete="email"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
class="input pl-11"
|
class="input pl-11"
|
||||||
|
|||||||
@@ -66,6 +66,7 @@
|
|||||||
v-model="formData.email"
|
v-model="formData.email"
|
||||||
type="email"
|
type="email"
|
||||||
required
|
required
|
||||||
|
autofocus
|
||||||
autocomplete="email"
|
autocomplete="email"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
class="input pl-11"
|
class="input pl-11"
|
||||||
|
|||||||
@@ -563,13 +563,13 @@ const installing = ref(false)
|
|||||||
const confirmPassword = ref('')
|
const confirmPassword = ref('')
|
||||||
const serviceReady = ref(false)
|
const serviceReady = ref(false)
|
||||||
|
|
||||||
// Get current server port from browser location (set by install.sh)
|
// Default server port
|
||||||
const getCurrentPort = (): number => {
|
const getCurrentPort = (): number => {
|
||||||
const port = window.location.port
|
const port = window.location.port
|
||||||
if (port) {
|
if (port) {
|
||||||
return parseInt(port, 10)
|
return parseInt(port, 10)
|
||||||
}
|
}
|
||||||
// Default port based on protocol
|
|
||||||
return window.location.protocol === 'https:' ? 443 : 80
|
return window.location.protocol === 'https:' ? 443 : 80
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -674,42 +674,35 @@ async function performInstall() {
|
|||||||
|
|
||||||
// Wait for service to restart and become available
|
// Wait for service to restart and become available
|
||||||
async function waitForServiceRestart() {
|
async function waitForServiceRestart() {
|
||||||
const maxAttempts = 30 // 30 attempts, ~30 seconds max
|
const maxAttempts = 60 // Increase to 60 attempts, ~60 seconds max
|
||||||
const interval = 1000 // 1 second between attempts
|
const interval = 1000 // 1 second between attempts
|
||||||
|
|
||||||
// Wait a moment for the service to start restarting
|
// Wait a moment for the service to start restarting
|
||||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
await new Promise((resolve) => setTimeout(resolve, 3000))
|
||||||
|
|
||||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
try {
|
try {
|
||||||
// Try to access the health endpoint
|
// Use setup status endpoint as it tells us the real mode
|
||||||
const response = await fetch('/health', {
|
// Service might return 404 or connection refused while restarting
|
||||||
|
const response = await fetch('/setup/status', {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
cache: 'no-store'
|
cache: 'no-store'
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
// Service is up, check if setup is no longer needed
|
const data = await response.json()
|
||||||
const statusResponse = await fetch('/setup/status', {
|
// If needs_setup is false, service has restarted in normal mode
|
||||||
method: 'GET',
|
if (data.data && !data.data.needs_setup) {
|
||||||
cache: 'no-store'
|
serviceReady.value = true
|
||||||
})
|
// Redirect to login page after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
if (statusResponse.ok) {
|
window.location.href = '/login'
|
||||||
const data = await statusResponse.json()
|
}, 1500)
|
||||||
// If needs_setup is false, service has restarted in normal mode
|
return
|
||||||
if (data.data && !data.data.needs_setup) {
|
|
||||||
serviceReady.value = true
|
|
||||||
// Redirect to login page after a short delay
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.href = '/login'
|
|
||||||
}, 1500)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Service not ready yet, continue polling
|
// Service not ready or network error during restart, continue polling
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, interval))
|
await new Promise((resolve) => setTimeout(resolve, interval))
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,16 +656,24 @@ 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) {
|
||||||
console.error('Failed to load usage stats:', e)
|
if (!isAbortError(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 {
|
||||||
loading.value = false
|
if (abortController === controller) {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 = ''
|
||||||
|
|
||||||
|
|||||||
@@ -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,22 +556,40 @@ 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 {
|
||||||
loading.value = false
|
if (abortController === currentAbortController) {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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'))
|
appStore.showWarning(t('usage.noDataToExport'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = [
|
exporting.value = true
|
||||||
'Model',
|
appStore.showInfo(t('usage.preparingExport'))
|
||||||
'Type',
|
|
||||||
'Input Tokens',
|
|
||||||
'Output Tokens',
|
|
||||||
'Cache Read Tokens',
|
|
||||||
'Cache Write Tokens',
|
|
||||||
'Total Cost',
|
|
||||||
'Billing Type',
|
|
||||||
'First Token (ms)',
|
|
||||||
'Duration (ms)',
|
|
||||||
'Time'
|
|
||||||
]
|
|
||||||
const rows = usageLogs.value.map((log) => [
|
|
||||||
log.model,
|
|
||||||
log.stream ? 'Stream' : 'Sync',
|
|
||||||
log.input_tokens,
|
|
||||||
log.output_tokens,
|
|
||||||
log.cache_read_tokens,
|
|
||||||
log.cache_creation_tokens,
|
|
||||||
log.total_cost.toFixed(6),
|
|
||||||
log.billing_type === 1 ? 'Subscription' : 'Balance',
|
|
||||||
log.first_token_ms ?? '',
|
|
||||||
log.duration_ms,
|
|
||||||
log.created_at
|
|
||||||
])
|
|
||||||
|
|
||||||
const csvContent = [headers.join(','), ...rows.map((row) => row.join(','))].join('\n')
|
try {
|
||||||
|
const allLogs: UsageLog[] = []
|
||||||
|
const pageSize = 100 // Use a larger page size for export to reduce requests
|
||||||
|
const totalRequests = Math.ceil(pagination.total / pageSize)
|
||||||
|
|
||||||
const blob = new Blob([csvContent], { type: 'text/csv' })
|
for (let page = 1; page <= totalRequests; page++) {
|
||||||
const url = window.URL.createObjectURL(blob)
|
const params: UsageQueryParams = {
|
||||||
const link = document.createElement('a')
|
page: page,
|
||||||
link.href = url
|
page_size: pageSize,
|
||||||
link.download = `usage_${new Date().toISOString().split('T')[0]}.csv`
|
...filters.value
|
||||||
link.click()
|
}
|
||||||
window.URL.revokeObjectURL(url)
|
const response = await usageAPI.query(params)
|
||||||
|
allLogs.push(...response.items)
|
||||||
|
}
|
||||||
|
|
||||||
appStore.showSuccess(t('usage.exportSuccess'))
|
if (allLogs.length === 0) {
|
||||||
|
appStore.showWarning(t('usage.noDataToExport'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
'Time',
|
||||||
|
'API Key Name',
|
||||||
|
'Model',
|
||||||
|
'Type',
|
||||||
|
'Input Tokens',
|
||||||
|
'Output Tokens',
|
||||||
|
'Cache Read Tokens',
|
||||||
|
'Cache Creation Tokens',
|
||||||
|
'Rate Multiplier',
|
||||||
|
'Billed Cost',
|
||||||
|
'Original Cost',
|
||||||
|
'Billing Type',
|
||||||
|
'First Token (ms)',
|
||||||
|
'Duration (ms)'
|
||||||
|
]
|
||||||
|
const rows = allLogs.map((log) =>
|
||||||
|
[
|
||||||
|
log.created_at,
|
||||||
|
log.api_key?.name || '',
|
||||||
|
log.model,
|
||||||
|
log.stream ? 'Stream' : 'Sync',
|
||||||
|
log.input_tokens,
|
||||||
|
log.output_tokens,
|
||||||
|
log.cache_read_tokens,
|
||||||
|
log.cache_creation_tokens,
|
||||||
|
log.rate_multiplier,
|
||||||
|
log.actual_cost.toFixed(8),
|
||||||
|
log.total_cost.toFixed(8),
|
||||||
|
log.billing_type === 1 ? 'Subscription' : 'Balance',
|
||||||
|
log.first_token_ms ?? '',
|
||||||
|
log.duration_ms
|
||||||
|
].map(escapeCSVValue)
|
||||||
|
)
|
||||||
|
|
||||||
|
const csvContent = [
|
||||||
|
headers.map(escapeCSVValue).join(','),
|
||||||
|
...rows.map((row) => row.join(','))
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = `usage_${filters.value.start_date}_to_${filters.value.end_date}.csv`
|
||||||
|
link.click()
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
|
||||||
|
appStore.showSuccess(t('usage.exportSuccess'))
|
||||||
|
} catch (error) {
|
||||||
|
appStore.showError(t('usage.exportFailed'))
|
||||||
|
console.error('CSV Export failed:', error)
|
||||||
|
} finally {
|
||||||
|
exporting.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tooltip functions
|
// Tooltip functions
|
||||||
|
|||||||
Reference in New Issue
Block a user