Merge remote-tracking branch 'upstream/main'
This commit is contained in:
@@ -50,6 +50,7 @@ export interface TrendParams {
|
||||
account_id?: number
|
||||
group_id?: number
|
||||
stream?: boolean
|
||||
billing_type?: number | null
|
||||
}
|
||||
|
||||
export interface TrendResponse {
|
||||
@@ -78,6 +79,7 @@ export interface ModelStatsParams {
|
||||
account_id?: number
|
||||
group_id?: number
|
||||
stream?: boolean
|
||||
billing_type?: number | null
|
||||
}
|
||||
|
||||
export interface ModelStatsResponse {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { apiClient } from '../client'
|
||||
import type {
|
||||
Group,
|
||||
AdminGroup,
|
||||
GroupPlatform,
|
||||
CreateGroupRequest,
|
||||
UpdateGroupRequest,
|
||||
@@ -31,8 +31,8 @@ export async function list(
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<PaginatedResponse<Group>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<Group>>('/admin/groups', {
|
||||
): Promise<PaginatedResponse<AdminGroup>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<AdminGroup>>('/admin/groups', {
|
||||
params: {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
@@ -48,8 +48,8 @@ export async function list(
|
||||
* @param platform - Optional platform filter
|
||||
* @returns List of all active groups
|
||||
*/
|
||||
export async function getAll(platform?: GroupPlatform): Promise<Group[]> {
|
||||
const { data } = await apiClient.get<Group[]>('/admin/groups/all', {
|
||||
export async function getAll(platform?: GroupPlatform): Promise<AdminGroup[]> {
|
||||
const { data } = await apiClient.get<AdminGroup[]>('/admin/groups/all', {
|
||||
params: platform ? { platform } : undefined
|
||||
})
|
||||
return data
|
||||
@@ -60,7 +60,7 @@ export async function getAll(platform?: GroupPlatform): Promise<Group[]> {
|
||||
* @param platform - Platform to filter by
|
||||
* @returns List of groups for the specified platform
|
||||
*/
|
||||
export async function getByPlatform(platform: GroupPlatform): Promise<Group[]> {
|
||||
export async function getByPlatform(platform: GroupPlatform): Promise<AdminGroup[]> {
|
||||
return getAll(platform)
|
||||
}
|
||||
|
||||
@@ -69,8 +69,8 @@ export async function getByPlatform(platform: GroupPlatform): Promise<Group[]> {
|
||||
* @param id - Group ID
|
||||
* @returns Group details
|
||||
*/
|
||||
export async function getById(id: number): Promise<Group> {
|
||||
const { data } = await apiClient.get<Group>(`/admin/groups/${id}`)
|
||||
export async function getById(id: number): Promise<AdminGroup> {
|
||||
const { data } = await apiClient.get<AdminGroup>(`/admin/groups/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -79,8 +79,8 @@ export async function getById(id: number): Promise<Group> {
|
||||
* @param groupData - Group data
|
||||
* @returns Created group
|
||||
*/
|
||||
export async function create(groupData: CreateGroupRequest): Promise<Group> {
|
||||
const { data } = await apiClient.post<Group>('/admin/groups', groupData)
|
||||
export async function create(groupData: CreateGroupRequest): Promise<AdminGroup> {
|
||||
const { data } = await apiClient.post<AdminGroup>('/admin/groups', groupData)
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -90,8 +90,8 @@ export async function create(groupData: CreateGroupRequest): Promise<Group> {
|
||||
* @param updates - Fields to update
|
||||
* @returns Updated group
|
||||
*/
|
||||
export async function update(id: number, updates: UpdateGroupRequest): Promise<Group> {
|
||||
const { data } = await apiClient.put<Group>(`/admin/groups/${id}`, updates)
|
||||
export async function update(id: number, updates: UpdateGroupRequest): Promise<AdminGroup> {
|
||||
const { data } = await apiClient.put<AdminGroup>(`/admin/groups/${id}`, updates)
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ export async function deleteGroup(id: number): Promise<{ message: string }> {
|
||||
* @param status - New status
|
||||
* @returns Updated group
|
||||
*/
|
||||
export async function toggleStatus(id: number, status: 'active' | 'inactive'): Promise<Group> {
|
||||
export async function toggleStatus(id: number, status: 'active' | 'inactive'): Promise<AdminGroup> {
|
||||
return update(id, { status })
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface SystemSettings {
|
||||
contact_info: string
|
||||
doc_url: string
|
||||
home_content: string
|
||||
hide_ccs_import_button: boolean
|
||||
// SMTP settings
|
||||
smtp_host: string
|
||||
smtp_port: number
|
||||
@@ -72,6 +73,7 @@ export interface UpdateSettingsRequest {
|
||||
contact_info?: string
|
||||
doc_url?: string
|
||||
home_content?: string
|
||||
hide_ccs_import_button?: boolean
|
||||
smtp_host?: string
|
||||
smtp_port?: number
|
||||
smtp_username?: string
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
import type { UsageLog, UsageQueryParams, PaginatedResponse } from '@/types'
|
||||
import type { AdminUsageLog, UsageQueryParams, PaginatedResponse } from '@/types'
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
@@ -31,6 +31,46 @@ export interface SimpleApiKey {
|
||||
user_id: number
|
||||
}
|
||||
|
||||
export interface UsageCleanupFilters {
|
||||
start_time: string
|
||||
end_time: string
|
||||
user_id?: number
|
||||
api_key_id?: number
|
||||
account_id?: number
|
||||
group_id?: number
|
||||
model?: string | null
|
||||
stream?: boolean | null
|
||||
billing_type?: number | null
|
||||
}
|
||||
|
||||
export interface UsageCleanupTask {
|
||||
id: number
|
||||
status: string
|
||||
filters: UsageCleanupFilters
|
||||
created_by: number
|
||||
deleted_rows: number
|
||||
error_message?: string | null
|
||||
canceled_by?: number | null
|
||||
canceled_at?: string | null
|
||||
started_at?: string | null
|
||||
finished_at?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CreateUsageCleanupTaskRequest {
|
||||
start_date: string
|
||||
end_date: string
|
||||
user_id?: number
|
||||
api_key_id?: number
|
||||
account_id?: number
|
||||
group_id?: number
|
||||
model?: string | null
|
||||
stream?: boolean | null
|
||||
billing_type?: number | null
|
||||
timezone?: string
|
||||
}
|
||||
|
||||
export interface AdminUsageQueryParams extends UsageQueryParams {
|
||||
user_id?: number
|
||||
}
|
||||
@@ -45,8 +85,8 @@ export interface AdminUsageQueryParams extends UsageQueryParams {
|
||||
export async function list(
|
||||
params: AdminUsageQueryParams,
|
||||
options?: { signal?: AbortSignal }
|
||||
): Promise<PaginatedResponse<UsageLog>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<UsageLog>>('/admin/usage', {
|
||||
): Promise<PaginatedResponse<AdminUsageLog>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<AdminUsageLog>>('/admin/usage', {
|
||||
params,
|
||||
signal: options?.signal
|
||||
})
|
||||
@@ -108,11 +148,51 @@ export async function searchApiKeys(userId?: number, keyword?: string): Promise<
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* List usage cleanup tasks (admin only)
|
||||
* @param params - Query parameters for pagination
|
||||
* @returns Paginated list of cleanup tasks
|
||||
*/
|
||||
export async function listCleanupTasks(
|
||||
params: { page?: number; page_size?: number },
|
||||
options?: { signal?: AbortSignal }
|
||||
): Promise<PaginatedResponse<UsageCleanupTask>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<UsageCleanupTask>>('/admin/usage/cleanup-tasks', {
|
||||
params,
|
||||
signal: options?.signal
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a usage cleanup task (admin only)
|
||||
* @param payload - Cleanup task parameters
|
||||
* @returns Created cleanup task
|
||||
*/
|
||||
export async function createCleanupTask(payload: CreateUsageCleanupTaskRequest): Promise<UsageCleanupTask> {
|
||||
const { data } = await apiClient.post<UsageCleanupTask>('/admin/usage/cleanup-tasks', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a usage cleanup task (admin only)
|
||||
* @param taskId - Task ID to cancel
|
||||
*/
|
||||
export async function cancelCleanupTask(taskId: number): Promise<{ id: number; status: string }> {
|
||||
const { data } = await apiClient.post<{ id: number; status: string }>(
|
||||
`/admin/usage/cleanup-tasks/${taskId}/cancel`
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export const adminUsageAPI = {
|
||||
list,
|
||||
getStats,
|
||||
searchUsers,
|
||||
searchApiKeys
|
||||
searchApiKeys,
|
||||
listCleanupTasks,
|
||||
createCleanupTask,
|
||||
cancelCleanupTask
|
||||
}
|
||||
|
||||
export default adminUsageAPI
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
import type { User, UpdateUserRequest, PaginatedResponse } from '@/types'
|
||||
import type { AdminUser, UpdateUserRequest, PaginatedResponse } from '@/types'
|
||||
|
||||
/**
|
||||
* List all users with pagination
|
||||
@@ -26,7 +26,7 @@ export async function list(
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<PaginatedResponse<User>> {
|
||||
): Promise<PaginatedResponse<AdminUser>> {
|
||||
// Build params with attribute filters in attr[id]=value format
|
||||
const params: Record<string, any> = {
|
||||
page,
|
||||
@@ -44,8 +44,7 @@ export async function list(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { data } = await apiClient.get<PaginatedResponse<User>>('/admin/users', {
|
||||
const { data } = await apiClient.get<PaginatedResponse<AdminUser>>('/admin/users', {
|
||||
params,
|
||||
signal: options?.signal
|
||||
})
|
||||
@@ -57,8 +56,8 @@ export async function list(
|
||||
* @param id - User ID
|
||||
* @returns User details
|
||||
*/
|
||||
export async function getById(id: number): Promise<User> {
|
||||
const { data } = await apiClient.get<User>(`/admin/users/${id}`)
|
||||
export async function getById(id: number): Promise<AdminUser> {
|
||||
const { data } = await apiClient.get<AdminUser>(`/admin/users/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -73,8 +72,8 @@ export async function create(userData: {
|
||||
balance?: number
|
||||
concurrency?: number
|
||||
allowed_groups?: number[] | null
|
||||
}): Promise<User> {
|
||||
const { data } = await apiClient.post<User>('/admin/users', userData)
|
||||
}): Promise<AdminUser> {
|
||||
const { data } = await apiClient.post<AdminUser>('/admin/users', userData)
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -84,8 +83,8 @@ export async function create(userData: {
|
||||
* @param updates - Fields to update
|
||||
* @returns Updated user
|
||||
*/
|
||||
export async function update(id: number, updates: UpdateUserRequest): Promise<User> {
|
||||
const { data } = await apiClient.put<User>(`/admin/users/${id}`, updates)
|
||||
export async function update(id: number, updates: UpdateUserRequest): Promise<AdminUser> {
|
||||
const { data } = await apiClient.put<AdminUser>(`/admin/users/${id}`, updates)
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -112,8 +111,8 @@ export async function updateBalance(
|
||||
balance: number,
|
||||
operation: 'set' | 'add' | 'subtract' = 'set',
|
||||
notes?: string
|
||||
): Promise<User> {
|
||||
const { data } = await apiClient.post<User>(`/admin/users/${id}/balance`, {
|
||||
): Promise<AdminUser> {
|
||||
const { data } = await apiClient.post<AdminUser>(`/admin/users/${id}/balance`, {
|
||||
balance,
|
||||
operation,
|
||||
notes: notes || ''
|
||||
@@ -127,7 +126,7 @@ export async function updateBalance(
|
||||
* @param concurrency - New concurrency limit
|
||||
* @returns Updated user
|
||||
*/
|
||||
export async function updateConcurrency(id: number, concurrency: number): Promise<User> {
|
||||
export async function updateConcurrency(id: number, concurrency: number): Promise<AdminUser> {
|
||||
return update(id, { concurrency })
|
||||
}
|
||||
|
||||
@@ -137,7 +136,7 @@ export async function updateConcurrency(id: number, concurrency: number): Promis
|
||||
* @param status - New status
|
||||
* @returns Updated user
|
||||
*/
|
||||
export async function toggleStatus(id: number, status: 'active' | 'disabled'): Promise<User> {
|
||||
export async function toggleStatus(id: number, status: 'active' | 'disabled'): Promise<AdminUser> {
|
||||
return update(id, { status })
|
||||
}
|
||||
|
||||
|
||||
@@ -648,7 +648,7 @@ import { ref, watch, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Proxy, Group } from '@/types'
|
||||
import type { Proxy, AdminGroup } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import ProxySelector from '@/components/common/ProxySelector.vue'
|
||||
@@ -659,7 +659,7 @@ interface Props {
|
||||
show: boolean
|
||||
accountIds: number[]
|
||||
proxies: Proxy[]
|
||||
groups: Group[]
|
||||
groups: AdminGroup[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
@@ -1346,6 +1346,33 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session ID Masking -->
|
||||
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.quotaControl.sessionIdMasking.label') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.quotaControl.sessionIdMasking.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="sessionIdMaskingEnabled = !sessionIdMaskingEnabled"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
sessionIdMaskingEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
sessionIdMaskingEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -1789,7 +1816,7 @@ import {
|
||||
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
|
||||
import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
|
||||
import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth'
|
||||
import type { Proxy, Group, AccountPlatform, AccountType } from '@/types'
|
||||
import type { Proxy, AdminGroup, AccountPlatform, AccountType } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import ProxySelector from '@/components/common/ProxySelector.vue'
|
||||
@@ -1835,7 +1862,7 @@ const apiKeyHint = computed(() => {
|
||||
interface Props {
|
||||
show: boolean
|
||||
proxies: Proxy[]
|
||||
groups: Group[]
|
||||
groups: AdminGroup[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -1928,6 +1955,7 @@ const sessionLimitEnabled = ref(false)
|
||||
const maxSessions = ref<number | null>(null)
|
||||
const sessionIdleTimeout = ref<number | null>(null)
|
||||
const tlsFingerprintEnabled = ref(false)
|
||||
const sessionIdMaskingEnabled = ref(false)
|
||||
|
||||
// Gemini tier selection (used as fallback when auto-detection is unavailable/fails)
|
||||
const geminiTierGoogleOne = ref<'google_one_free' | 'google_ai_pro' | 'google_ai_ultra'>('google_one_free')
|
||||
@@ -2314,6 +2342,7 @@ const resetForm = () => {
|
||||
maxSessions.value = null
|
||||
sessionIdleTimeout.value = null
|
||||
tlsFingerprintEnabled.value = false
|
||||
sessionIdMaskingEnabled.value = false
|
||||
tempUnschedEnabled.value = false
|
||||
tempUnschedRules.value = []
|
||||
geminiOAuthType.value = 'code_assist'
|
||||
@@ -2602,6 +2631,11 @@ const handleAnthropicExchange = async (authCode: string) => {
|
||||
extra.enable_tls_fingerprint = true
|
||||
}
|
||||
|
||||
// Add session ID masking settings
|
||||
if (sessionIdMaskingEnabled.value) {
|
||||
extra.session_id_masking_enabled = true
|
||||
}
|
||||
|
||||
const credentials = {
|
||||
...tokenInfo,
|
||||
...(interceptWarmupRequests.value ? { intercept_warmup_requests: true } : {})
|
||||
@@ -2690,6 +2724,11 @@ const handleCookieAuth = async (sessionKey: string) => {
|
||||
extra.enable_tls_fingerprint = true
|
||||
}
|
||||
|
||||
// Add session ID masking settings
|
||||
if (sessionIdMaskingEnabled.value) {
|
||||
extra.session_id_masking_enabled = true
|
||||
}
|
||||
|
||||
const accountName = keys.length > 1 ? `${form.name} #${i + 1}` : form.name
|
||||
|
||||
// Merge interceptWarmupRequests into credentials
|
||||
|
||||
@@ -759,6 +759,33 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session ID Masking -->
|
||||
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.quotaControl.sessionIdMasking.label') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.quotaControl.sessionIdMasking.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="sessionIdMaskingEnabled = !sessionIdMaskingEnabled"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
sessionIdMaskingEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
sessionIdMaskingEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
@@ -856,7 +883,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Account, Proxy, Group } from '@/types'
|
||||
import type { Account, Proxy, AdminGroup } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
@@ -874,7 +901,7 @@ interface Props {
|
||||
show: boolean
|
||||
account: Account | null
|
||||
proxies: Proxy[]
|
||||
groups: Group[]
|
||||
groups: AdminGroup[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -932,6 +959,7 @@ const sessionLimitEnabled = ref(false)
|
||||
const maxSessions = ref<number | null>(null)
|
||||
const sessionIdleTimeout = ref<number | null>(null)
|
||||
const tlsFingerprintEnabled = ref(false)
|
||||
const sessionIdMaskingEnabled = ref(false)
|
||||
|
||||
// Computed: current preset mappings based on platform
|
||||
const presetMappings = computed(() => getPresetMappingsByPlatform(props.account?.platform || 'anthropic'))
|
||||
@@ -1266,6 +1294,7 @@ function loadQuotaControlSettings(account: Account) {
|
||||
maxSessions.value = null
|
||||
sessionIdleTimeout.value = null
|
||||
tlsFingerprintEnabled.value = false
|
||||
sessionIdMaskingEnabled.value = false
|
||||
|
||||
// Only applies to Anthropic OAuth/SetupToken accounts
|
||||
if (account.platform !== 'anthropic' || (account.type !== 'oauth' && account.type !== 'setup-token')) {
|
||||
@@ -1289,6 +1318,11 @@ function loadQuotaControlSettings(account: Account) {
|
||||
if (account.enable_tls_fingerprint === true) {
|
||||
tlsFingerprintEnabled.value = true
|
||||
}
|
||||
|
||||
// Load session ID masking setting
|
||||
if (account.session_id_masking_enabled === true) {
|
||||
sessionIdMaskingEnabled.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function formatTempUnschedKeywords(value: unknown) {
|
||||
@@ -1448,6 +1482,13 @@ const handleSubmit = async () => {
|
||||
delete newExtra.enable_tls_fingerprint
|
||||
}
|
||||
|
||||
// Session ID masking setting
|
||||
if (sessionIdMaskingEnabled.value) {
|
||||
newExtra.session_id_masking_enabled = true
|
||||
} else {
|
||||
delete newExtra.session_id_masking_enabled
|
||||
}
|
||||
|
||||
updatePayload.extra = newExtra
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<button @click="$emit('refresh')" :disabled="loading" class="btn btn-secondary">
|
||||
<Icon name="refresh" size="md" :class="[loading ? 'animate-spin' : '']" />
|
||||
</button>
|
||||
<slot name="after"></slot>
|
||||
<button @click="$emit('sync')" class="btn btn-secondary">{{ t('admin.accounts.syncFromCrs') }}</button>
|
||||
<button @click="$emit('create')" class="btn btn-primary">{{ t('admin.accounts.createAccount') }}</button>
|
||||
</div>
|
||||
|
||||
380
frontend/src/components/admin/usage/UsageCleanupDialog.vue
Normal file
380
frontend/src/components/admin/usage/UsageCleanupDialog.vue
Normal file
@@ -0,0 +1,380 @@
|
||||
<template>
|
||||
<BaseDialog :show="show" :title="t('admin.usage.cleanup.title')" width="wide" @close="handleClose">
|
||||
<div class="space-y-4">
|
||||
<UsageFilters
|
||||
v-model="localFilters"
|
||||
v-model:startDate="localStartDate"
|
||||
v-model:endDate="localEndDate"
|
||||
:exporting="false"
|
||||
:show-actions="false"
|
||||
@change="noop"
|
||||
/>
|
||||
|
||||
<div class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
|
||||
{{ t('admin.usage.cleanup.warning') }}
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 p-4 dark:border-dark-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-200">
|
||||
{{ t('admin.usage.cleanup.recentTasks') }}
|
||||
</h4>
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="loadTasks">
|
||||
{{ t('common.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 space-y-2">
|
||||
<div v-if="tasksLoading" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.usage.cleanup.loadingTasks') }}
|
||||
</div>
|
||||
<div v-else-if="tasks.length === 0" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.usage.cleanup.noTasks') }}
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
class="flex flex-col gap-2 rounded-lg border border-gray-100 px-3 py-2 text-sm text-gray-600 dark:border-dark-700 dark:text-gray-300"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="statusClass(task.status)" class="rounded-full px-2 py-0.5 text-xs font-semibold">
|
||||
{{ statusLabel(task.status) }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-400">#{{ task.id }}</span>
|
||||
<button
|
||||
v-if="canCancel(task)"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs text-rose-600 hover:text-rose-700 dark:text-rose-300"
|
||||
@click="openCancelConfirm(task)"
|
||||
>
|
||||
{{ t('admin.usage.cleanup.cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">
|
||||
{{ formatDateTime(task.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{{ t('admin.usage.cleanup.range') }}: {{ formatRange(task) }}</span>
|
||||
<span>{{ t('admin.usage.cleanup.deletedRows') }}: {{ task.deleted_rows.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="task.error_message" class="text-xs text-rose-500">
|
||||
{{ task.error_message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
v-if="tasksTotal > tasksPageSize"
|
||||
class="mt-4"
|
||||
:total="tasksTotal"
|
||||
:page="tasksPage"
|
||||
:page-size="tasksPageSize"
|
||||
:page-size-options="[5]"
|
||||
:show-page-size-selector="false"
|
||||
:show-jump="true"
|
||||
@update:page="handleTaskPageChange"
|
||||
@update:pageSize="handleTaskPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" class="btn btn-secondary" @click="handleClose">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger" :disabled="submitting" @click="openConfirm">
|
||||
{{ submitting ? t('admin.usage.cleanup.submitting') : t('admin.usage.cleanup.submit') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
:show="confirmVisible"
|
||||
:title="t('admin.usage.cleanup.confirmTitle')"
|
||||
:message="t('admin.usage.cleanup.confirmMessage')"
|
||||
:confirm-text="t('admin.usage.cleanup.confirmSubmit')"
|
||||
danger
|
||||
@confirm="submitCleanup"
|
||||
@cancel="confirmVisible = false"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:show="cancelConfirmVisible"
|
||||
:title="t('admin.usage.cleanup.cancelConfirmTitle')"
|
||||
:message="t('admin.usage.cleanup.cancelConfirmMessage')"
|
||||
:confirm-text="t('admin.usage.cleanup.cancelConfirm')"
|
||||
danger
|
||||
@confirm="cancelTask"
|
||||
@cancel="cancelConfirmVisible = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import UsageFilters from '@/components/admin/usage/UsageFilters.vue'
|
||||
import { adminUsageAPI } from '@/api/admin/usage'
|
||||
import type { AdminUsageQueryParams, UsageCleanupTask, CreateUsageCleanupTaskRequest } from '@/api/admin/usage'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
filters: AdminUsageQueryParams
|
||||
startDate: string
|
||||
endDate: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const localFilters = ref<AdminUsageQueryParams>({})
|
||||
const localStartDate = ref('')
|
||||
const localEndDate = ref('')
|
||||
|
||||
const tasks = ref<UsageCleanupTask[]>([])
|
||||
const tasksLoading = ref(false)
|
||||
const tasksPage = ref(1)
|
||||
const tasksPageSize = ref(5)
|
||||
const tasksTotal = ref(0)
|
||||
const submitting = ref(false)
|
||||
const confirmVisible = ref(false)
|
||||
const cancelConfirmVisible = ref(false)
|
||||
const canceling = ref(false)
|
||||
const cancelTarget = ref<UsageCleanupTask | null>(null)
|
||||
let pollTimer: number | null = null
|
||||
|
||||
const noop = () => {}
|
||||
|
||||
const resetFilters = () => {
|
||||
localFilters.value = { ...props.filters }
|
||||
localStartDate.value = props.startDate
|
||||
localEndDate.value = props.endDate
|
||||
localFilters.value.start_date = localStartDate.value
|
||||
localFilters.value.end_date = localEndDate.value
|
||||
tasksPage.value = 1
|
||||
tasksTotal.value = 0
|
||||
}
|
||||
|
||||
const startPolling = () => {
|
||||
stopPolling()
|
||||
pollTimer = window.setInterval(() => {
|
||||
loadTasks()
|
||||
}, 10000)
|
||||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollTimer !== null) {
|
||||
window.clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
stopPolling()
|
||||
confirmVisible.value = false
|
||||
cancelConfirmVisible.value = false
|
||||
canceling.value = false
|
||||
cancelTarget.value = null
|
||||
submitting.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const statusLabel = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
pending: t('admin.usage.cleanup.status.pending'),
|
||||
running: t('admin.usage.cleanup.status.running'),
|
||||
succeeded: t('admin.usage.cleanup.status.succeeded'),
|
||||
failed: t('admin.usage.cleanup.status.failed'),
|
||||
canceled: t('admin.usage.cleanup.status.canceled')
|
||||
}
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
const statusClass = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
pending: 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-200',
|
||||
running: 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-200',
|
||||
succeeded: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200',
|
||||
failed: 'bg-rose-100 text-rose-700 dark:bg-rose-500/20 dark:text-rose-200',
|
||||
canceled: 'bg-gray-200 text-gray-600 dark:bg-dark-600 dark:text-gray-300'
|
||||
}
|
||||
return map[status] || 'bg-gray-100 text-gray-600'
|
||||
}
|
||||
|
||||
const formatDateTime = (value?: string | null) => {
|
||||
if (!value) return '--'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
const formatRange = (task: UsageCleanupTask) => {
|
||||
const start = formatDateTime(task.filters.start_time)
|
||||
const end = formatDateTime(task.filters.end_time)
|
||||
return `${start} ~ ${end}`
|
||||
}
|
||||
|
||||
const getUserTimezone = () => {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
} catch {
|
||||
return 'UTC'
|
||||
}
|
||||
}
|
||||
|
||||
const loadTasks = async () => {
|
||||
if (!props.show) return
|
||||
tasksLoading.value = true
|
||||
try {
|
||||
const res = await adminUsageAPI.listCleanupTasks({
|
||||
page: tasksPage.value,
|
||||
page_size: tasksPageSize.value
|
||||
})
|
||||
tasks.value = res.items || []
|
||||
tasksTotal.value = res.total || 0
|
||||
if (res.page) {
|
||||
tasksPage.value = res.page
|
||||
}
|
||||
if (res.page_size) {
|
||||
tasksPageSize.value = res.page_size
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load cleanup tasks:', error)
|
||||
appStore.showError(t('admin.usage.cleanup.loadFailed'))
|
||||
} finally {
|
||||
tasksLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleTaskPageChange = (page: number) => {
|
||||
tasksPage.value = page
|
||||
loadTasks()
|
||||
}
|
||||
|
||||
const handleTaskPageSizeChange = (size: number) => {
|
||||
if (!Number.isFinite(size) || size <= 0) return
|
||||
tasksPageSize.value = size
|
||||
tasksPage.value = 1
|
||||
loadTasks()
|
||||
}
|
||||
|
||||
const openConfirm = () => {
|
||||
confirmVisible.value = true
|
||||
}
|
||||
|
||||
const canCancel = (task: UsageCleanupTask) => {
|
||||
return task.status === 'pending' || task.status === 'running'
|
||||
}
|
||||
|
||||
const openCancelConfirm = (task: UsageCleanupTask) => {
|
||||
cancelTarget.value = task
|
||||
cancelConfirmVisible.value = true
|
||||
}
|
||||
|
||||
const buildPayload = (): CreateUsageCleanupTaskRequest | null => {
|
||||
if (!localStartDate.value || !localEndDate.value) {
|
||||
appStore.showError(t('admin.usage.cleanup.missingRange'))
|
||||
return null
|
||||
}
|
||||
|
||||
const payload: CreateUsageCleanupTaskRequest = {
|
||||
start_date: localStartDate.value,
|
||||
end_date: localEndDate.value,
|
||||
timezone: getUserTimezone()
|
||||
}
|
||||
|
||||
if (localFilters.value.user_id && localFilters.value.user_id > 0) {
|
||||
payload.user_id = localFilters.value.user_id
|
||||
}
|
||||
if (localFilters.value.api_key_id && localFilters.value.api_key_id > 0) {
|
||||
payload.api_key_id = localFilters.value.api_key_id
|
||||
}
|
||||
if (localFilters.value.account_id && localFilters.value.account_id > 0) {
|
||||
payload.account_id = localFilters.value.account_id
|
||||
}
|
||||
if (localFilters.value.group_id && localFilters.value.group_id > 0) {
|
||||
payload.group_id = localFilters.value.group_id
|
||||
}
|
||||
if (localFilters.value.model) {
|
||||
payload.model = localFilters.value.model
|
||||
}
|
||||
if (localFilters.value.stream !== null && localFilters.value.stream !== undefined) {
|
||||
payload.stream = localFilters.value.stream
|
||||
}
|
||||
if (localFilters.value.billing_type !== null && localFilters.value.billing_type !== undefined) {
|
||||
payload.billing_type = localFilters.value.billing_type
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
const submitCleanup = async () => {
|
||||
const payload = buildPayload()
|
||||
if (!payload) {
|
||||
confirmVisible.value = false
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
confirmVisible.value = false
|
||||
try {
|
||||
await adminUsageAPI.createCleanupTask(payload)
|
||||
appStore.showSuccess(t('admin.usage.cleanup.submitSuccess'))
|
||||
loadTasks()
|
||||
} catch (error) {
|
||||
console.error('Failed to create cleanup task:', error)
|
||||
appStore.showError(t('admin.usage.cleanup.submitFailed'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const cancelTask = async () => {
|
||||
const task = cancelTarget.value
|
||||
if (!task) {
|
||||
cancelConfirmVisible.value = false
|
||||
return
|
||||
}
|
||||
canceling.value = true
|
||||
cancelConfirmVisible.value = false
|
||||
try {
|
||||
await adminUsageAPI.cancelCleanupTask(task.id)
|
||||
appStore.showSuccess(t('admin.usage.cleanup.cancelSuccess'))
|
||||
loadTasks()
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel cleanup task:', error)
|
||||
appStore.showError(t('admin.usage.cleanup.cancelFailed'))
|
||||
} finally {
|
||||
canceling.value = false
|
||||
cancelTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (show) {
|
||||
resetFilters()
|
||||
loadTasks()
|
||||
startPolling()
|
||||
} else {
|
||||
stopPolling()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
})
|
||||
</script>
|
||||
@@ -127,6 +127,12 @@
|
||||
<Select v-model="filters.stream" :options="streamTypeOptions" @change="emitChange" />
|
||||
</div>
|
||||
|
||||
<!-- Billing Type Filter -->
|
||||
<div class="w-full sm:w-auto sm:min-w-[200px]">
|
||||
<label class="input-label">{{ t('admin.usage.billingType') }}</label>
|
||||
<Select v-model="filters.billing_type" :options="billingTypeOptions" @change="emitChange" />
|
||||
</div>
|
||||
|
||||
<!-- Group Filter -->
|
||||
<div class="w-full sm:w-auto sm:min-w-[200px]">
|
||||
<label class="input-label">{{ t('admin.usage.group') }}</label>
|
||||
@@ -147,10 +153,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Right: actions -->
|
||||
<div class="flex w-full flex-wrap items-center justify-end gap-3 sm:w-auto">
|
||||
<div v-if="showActions" class="flex w-full flex-wrap items-center justify-end gap-3 sm:w-auto">
|
||||
<button type="button" @click="$emit('reset')" class="btn btn-secondary">
|
||||
{{ t('common.reset') }}
|
||||
</button>
|
||||
<button type="button" @click="$emit('cleanup')" class="btn btn-danger">
|
||||
{{ t('admin.usage.cleanup.button') }}
|
||||
</button>
|
||||
<button type="button" @click="$emit('export')" :disabled="exporting" class="btn btn-primary">
|
||||
{{ t('usage.exportExcel') }}
|
||||
</button>
|
||||
@@ -174,16 +183,20 @@ interface Props {
|
||||
exporting: boolean
|
||||
startDate: string
|
||||
endDate: string
|
||||
showActions?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showActions: true
|
||||
})
|
||||
const emit = defineEmits([
|
||||
'update:modelValue',
|
||||
'update:startDate',
|
||||
'update:endDate',
|
||||
'change',
|
||||
'reset',
|
||||
'export'
|
||||
'export',
|
||||
'cleanup'
|
||||
])
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -221,6 +234,12 @@ const streamTypeOptions = ref<SelectOption[]>([
|
||||
{ value: false, label: t('usage.sync') }
|
||||
])
|
||||
|
||||
const billingTypeOptions = ref<SelectOption[]>([
|
||||
{ value: null, label: t('admin.usage.allBillingTypes') },
|
||||
{ value: 0, label: t('admin.usage.billingTypeBalance') },
|
||||
{ value: 1, label: t('admin.usage.billingTypeSubscription') }
|
||||
])
|
||||
|
||||
const emitChange = () => emit('change')
|
||||
|
||||
const updateStartDate = (value: string) => {
|
||||
|
||||
@@ -239,7 +239,7 @@ import { formatDateTime } from '@/utils/format'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { UsageLog } from '@/types'
|
||||
import type { AdminUsageLog } from '@/types'
|
||||
|
||||
defineProps(['data', 'loading'])
|
||||
const { t } = useI18n()
|
||||
@@ -247,12 +247,12 @@ const { t } = useI18n()
|
||||
// Tooltip state - cost
|
||||
const tooltipVisible = ref(false)
|
||||
const tooltipPosition = ref({ x: 0, y: 0 })
|
||||
const tooltipData = ref<UsageLog | null>(null)
|
||||
const tooltipData = ref<AdminUsageLog | null>(null)
|
||||
|
||||
// Tooltip state - token
|
||||
const tokenTooltipVisible = ref(false)
|
||||
const tokenTooltipPosition = ref({ x: 0, y: 0 })
|
||||
const tokenTooltipData = ref<UsageLog | null>(null)
|
||||
const tokenTooltipData = ref<AdminUsageLog | null>(null)
|
||||
|
||||
const cols = computed(() => [
|
||||
{ key: 'user', label: t('admin.usage.user'), sortable: false },
|
||||
@@ -296,7 +296,7 @@ const formatDuration = (ms: number | null | undefined): string => {
|
||||
}
|
||||
|
||||
// Cost tooltip functions
|
||||
const showTooltip = (event: MouseEvent, row: UsageLog) => {
|
||||
const showTooltip = (event: MouseEvent, row: AdminUsageLog) => {
|
||||
const target = event.currentTarget as HTMLElement
|
||||
const rect = target.getBoundingClientRect()
|
||||
tooltipData.value = row
|
||||
@@ -311,7 +311,7 @@ const hideTooltip = () => {
|
||||
}
|
||||
|
||||
// Token tooltip functions
|
||||
const showTokenTooltip = (event: MouseEvent, row: UsageLog) => {
|
||||
const showTokenTooltip = (event: MouseEvent, row: AdminUsageLog) => {
|
||||
const target = event.currentTarget as HTMLElement
|
||||
const rect = target.getBoundingClientRect()
|
||||
tokenTooltipData.value = row
|
||||
|
||||
@@ -39,10 +39,10 @@ import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { User, Group } from '@/types'
|
||||
import type { AdminUser, Group } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
|
||||
const props = defineProps<{ show: boolean, user: User | null }>()
|
||||
const props = defineProps<{ show: boolean, user: AdminUser | null }>()
|
||||
const emit = defineEmits(['close', 'success']); const { t } = useI18n(); const appStore = useAppStore()
|
||||
|
||||
const groups = ref<Group[]>([]); const selectedIds = ref<number[]>([]); const loading = ref(false); const submitting = ref(false)
|
||||
@@ -56,4 +56,4 @@ const handleSave = async () => {
|
||||
appStore.showSuccess(t('admin.users.allowedGroupsUpdated')); emit('success'); emit('close')
|
||||
} catch (error) { console.error('Failed to update allowed groups:', error) } finally { submitting.value = false }
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -32,10 +32,10 @@ import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import type { User, ApiKey } from '@/types'
|
||||
import type { AdminUser, ApiKey } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
|
||||
const props = defineProps<{ show: boolean, user: User | null }>()
|
||||
const props = defineProps<{ show: boolean, user: AdminUser | null }>()
|
||||
defineEmits(['close']); const { t } = useI18n()
|
||||
const apiKeys = ref<ApiKey[]>([]); const loading = ref(false)
|
||||
|
||||
@@ -44,4 +44,4 @@ const load = async () => {
|
||||
if (!props.user) return; loading.value = true
|
||||
try { const res = await adminAPI.users.getUserApiKeys(props.user.id); apiKeys.value = res.items || [] } catch (error) { console.error('Failed to load API keys:', error) } finally { loading.value = false }
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -29,10 +29,10 @@ import { reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { User } from '@/types'
|
||||
import type { AdminUser } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
|
||||
const props = defineProps<{ show: boolean, user: User | null, operation: 'add' | 'subtract' }>()
|
||||
const props = defineProps<{ show: boolean, user: AdminUser | null, operation: 'add' | 'subtract' }>()
|
||||
const emit = defineEmits(['close', 'success']); const { t } = useI18n(); const appStore = useAppStore()
|
||||
|
||||
const submitting = ref(false); const form = reactive({ amount: 0, notes: '' })
|
||||
|
||||
@@ -56,12 +56,12 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { User, UserAttributeValuesMap } from '@/types'
|
||||
import type { AdminUser, UserAttributeValuesMap } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import UserAttributeForm from '@/components/user/UserAttributeForm.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const props = defineProps<{ show: boolean, user: User | null }>()
|
||||
const props = defineProps<{ show: boolean, user: AdminUser | null }>()
|
||||
const emit = defineEmits(['close', 'success'])
|
||||
const { t } = useI18n(); const appStore = useAppStore(); const { copyToClipboard } = useClipboard()
|
||||
|
||||
|
||||
@@ -42,13 +42,13 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import GroupBadge from './GroupBadge.vue'
|
||||
import type { Group, GroupPlatform } from '@/types'
|
||||
import type { AdminGroup, GroupPlatform } from '@/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Props {
|
||||
modelValue: number[]
|
||||
groups: Group[]
|
||||
groups: AdminGroup[]
|
||||
platform?: GroupPlatform // Optional platform filter
|
||||
mixedScheduling?: boolean // For antigravity accounts: allow anthropic/gemini groups
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
</p>
|
||||
|
||||
<!-- Page size selector -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<div v-if="showPageSizeSelector" class="flex items-center space-x-2">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300"
|
||||
>{{ t('pagination.perPage') }}:</span
|
||||
>
|
||||
@@ -49,6 +49,22 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showJump" class="flex items-center space-x-2">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('pagination.jumpTo') }}</span>
|
||||
<input
|
||||
v-model="jumpPage"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="totalPages"
|
||||
class="input w-20 text-sm"
|
||||
:placeholder="t('pagination.jumpPlaceholder')"
|
||||
@keyup.enter="submitJump"
|
||||
/>
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="submitJump">
|
||||
{{ t('pagination.jumpAction') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop pagination buttons -->
|
||||
@@ -102,7 +118,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import Select from './Select.vue'
|
||||
@@ -114,6 +130,8 @@ interface Props {
|
||||
page: number
|
||||
pageSize: number
|
||||
pageSizeOptions?: number[]
|
||||
showPageSizeSelector?: boolean
|
||||
showJump?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -122,7 +140,9 @@ interface Emits {
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
pageSizeOptions: () => [10, 20, 50, 100]
|
||||
pageSizeOptions: () => [10, 20, 50, 100],
|
||||
showPageSizeSelector: true,
|
||||
showJump: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
@@ -146,6 +166,8 @@ const pageSizeSelectOptions = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
const jumpPage = ref('')
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const pages: (number | string)[] = []
|
||||
const maxVisible = 7
|
||||
@@ -196,6 +218,16 @@ const handlePageSizeChange = (value: string | number | boolean | null) => {
|
||||
const newPageSize = typeof value === 'string' ? parseInt(value) : value
|
||||
emit('update:pageSize', newPageSize)
|
||||
}
|
||||
|
||||
const submitJump = () => {
|
||||
const value = jumpPage.value.trim()
|
||||
if (!value) return
|
||||
const pageNum = Number.parseInt(value, 10)
|
||||
if (Number.isNaN(pageNum)) return
|
||||
const nextPage = Math.min(Math.max(pageNum, 1), totalPages.value)
|
||||
jumpPage.value = ''
|
||||
goToPage(nextPage)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -21,8 +21,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Language + Subscriptions + Balance + User Dropdown -->
|
||||
<!-- Right: Docs + Language + Subscriptions + Balance + User Dropdown -->
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Docs Link -->
|
||||
<a
|
||||
v-if="docUrl"
|
||||
:href="docUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-dark-400 dark:hover:bg-dark-800 dark:hover:text-white"
|
||||
>
|
||||
<Icon name="book" size="sm" />
|
||||
<span class="hidden sm:inline">{{ t('nav.docs') }}</span>
|
||||
</a>
|
||||
|
||||
<!-- Language Switcher -->
|
||||
<LocaleSwitcher />
|
||||
|
||||
@@ -194,6 +206,7 @@ const user = computed(() => authStore.user)
|
||||
const dropdownOpen = ref(false)
|
||||
const dropdownRef = ref<HTMLElement | null>(null)
|
||||
const contactInfo = computed(() => appStore.contactInfo)
|
||||
const docUrl = computed(() => appStore.docUrl)
|
||||
|
||||
// 只在标准模式的管理员下显示新手引导按钮
|
||||
const showOnboardingButton = computed(() => {
|
||||
|
||||
@@ -196,7 +196,8 @@ export default {
|
||||
expand: 'Expand',
|
||||
logout: 'Logout',
|
||||
github: 'GitHub',
|
||||
mySubscriptions: 'My Subscriptions'
|
||||
mySubscriptions: 'My Subscriptions',
|
||||
docs: 'Docs'
|
||||
},
|
||||
|
||||
// Auth
|
||||
@@ -572,7 +573,10 @@ export default {
|
||||
previous: 'Previous',
|
||||
next: 'Next',
|
||||
perPage: 'Per page',
|
||||
goToPage: 'Go to page {page}'
|
||||
goToPage: 'Go to page {page}',
|
||||
jumpTo: 'Jump to',
|
||||
jumpPlaceholder: 'Page',
|
||||
jumpAction: 'Go'
|
||||
},
|
||||
|
||||
// Errors
|
||||
@@ -946,7 +950,7 @@ export default {
|
||||
title: 'Subscription Management',
|
||||
description: 'Manage user subscriptions and quota limits',
|
||||
assignSubscription: 'Assign Subscription',
|
||||
extendSubscription: 'Extend Subscription',
|
||||
adjustSubscription: 'Adjust Subscription',
|
||||
revokeSubscription: 'Revoke Subscription',
|
||||
allStatus: 'All Status',
|
||||
allGroups: 'All Groups',
|
||||
@@ -961,6 +965,7 @@ export default {
|
||||
resetInHoursMinutes: 'Resets in {hours}h {minutes}m',
|
||||
resetInDaysHours: 'Resets in {days}d {hours}h',
|
||||
daysRemaining: 'days remaining',
|
||||
remainingDays: 'Remaining days',
|
||||
noExpiration: 'No expiration',
|
||||
status: {
|
||||
active: 'Active',
|
||||
@@ -979,28 +984,32 @@ export default {
|
||||
user: 'User',
|
||||
group: 'Subscription Group',
|
||||
validityDays: 'Validity (Days)',
|
||||
extendDays: 'Extend by (Days)'
|
||||
adjustDays: 'Adjust by (Days)'
|
||||
},
|
||||
selectUser: 'Select a user',
|
||||
selectGroup: 'Select a subscription group',
|
||||
groupHint: 'Only groups with subscription billing type are shown',
|
||||
validityHint: 'Number of days the subscription will be valid',
|
||||
extendingFor: 'Extending subscription for',
|
||||
adjustingFor: 'Adjusting subscription for',
|
||||
currentExpiration: 'Current expiration',
|
||||
adjustDaysPlaceholder: 'Positive to extend, negative to shorten',
|
||||
adjustHint: 'Enter positive number to extend, negative to shorten (remaining days must be > 0)',
|
||||
assign: 'Assign',
|
||||
assigning: 'Assigning...',
|
||||
extend: 'Extend',
|
||||
extending: 'Extending...',
|
||||
adjust: 'Adjust',
|
||||
adjusting: 'Adjusting...',
|
||||
revoke: 'Revoke',
|
||||
noSubscriptionsYet: 'No subscriptions yet',
|
||||
assignFirstSubscription: 'Assign a subscription to get started.',
|
||||
subscriptionAssigned: 'Subscription assigned successfully',
|
||||
subscriptionExtended: 'Subscription extended successfully',
|
||||
subscriptionAdjusted: 'Subscription adjusted successfully',
|
||||
subscriptionRevoked: 'Subscription revoked successfully',
|
||||
failedToLoad: 'Failed to load subscriptions',
|
||||
failedToAssign: 'Failed to assign subscription',
|
||||
failedToExtend: 'Failed to extend subscription',
|
||||
failedToAdjust: 'Failed to adjust subscription',
|
||||
failedToRevoke: 'Failed to revoke subscription',
|
||||
adjustWouldExpire: 'Remaining days after adjustment must be greater than 0',
|
||||
adjustOutOfRange: 'Adjustment days must be between -36500 and 36500',
|
||||
pleaseSelectUser: 'Please select a user',
|
||||
pleaseSelectGroup: 'Please select a group',
|
||||
validityDaysRequired: 'Please enter a valid number of days (at least 1)',
|
||||
@@ -1289,6 +1298,10 @@ export default {
|
||||
tlsFingerprint: {
|
||||
label: 'TLS Fingerprint Simulation',
|
||||
hint: 'Simulate Node.js/Claude Code client TLS fingerprint'
|
||||
},
|
||||
sessionIdMasking: {
|
||||
label: 'Session ID Masking',
|
||||
hint: 'When enabled, fixes the session ID in metadata.user_id for 15 minutes, making upstream think requests come from the same session'
|
||||
}
|
||||
},
|
||||
expired: 'Expired',
|
||||
@@ -1937,7 +1950,43 @@ export default {
|
||||
cacheCreationTokens: 'Cache Creation Tokens',
|
||||
cacheReadTokens: 'Cache Read Tokens',
|
||||
failedToLoad: 'Failed to load usage records',
|
||||
ipAddress: 'IP'
|
||||
billingType: 'Billing Type',
|
||||
allBillingTypes: 'All Billing Types',
|
||||
billingTypeBalance: 'Balance',
|
||||
billingTypeSubscription: 'Subscription',
|
||||
ipAddress: 'IP',
|
||||
cleanup: {
|
||||
button: 'Cleanup',
|
||||
title: 'Cleanup Usage Records',
|
||||
warning: 'Cleanup is irreversible and will affect historical stats.',
|
||||
submit: 'Submit Cleanup',
|
||||
submitting: 'Submitting...',
|
||||
confirmTitle: 'Confirm Cleanup',
|
||||
confirmMessage: 'Are you sure you want to submit this cleanup task? This action cannot be undone.',
|
||||
confirmSubmit: 'Confirm Cleanup',
|
||||
cancel: 'Cancel',
|
||||
cancelConfirmTitle: 'Confirm Cancel',
|
||||
cancelConfirmMessage: 'Are you sure you want to cancel this cleanup task?',
|
||||
cancelConfirm: 'Confirm Cancel',
|
||||
cancelSuccess: 'Cleanup task canceled',
|
||||
cancelFailed: 'Failed to cancel cleanup task',
|
||||
recentTasks: 'Recent Cleanup Tasks',
|
||||
loadingTasks: 'Loading tasks...',
|
||||
noTasks: 'No cleanup tasks yet',
|
||||
range: 'Range',
|
||||
deletedRows: 'Deleted',
|
||||
missingRange: 'Please select a date range',
|
||||
submitSuccess: 'Cleanup task created',
|
||||
submitFailed: 'Failed to create cleanup task',
|
||||
loadFailed: 'Failed to load cleanup tasks',
|
||||
status: {
|
||||
pending: 'Pending',
|
||||
running: 'Running',
|
||||
succeeded: 'Succeeded',
|
||||
failed: 'Failed',
|
||||
canceled: 'Canceled'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Ops Monitoring
|
||||
@@ -2747,7 +2796,9 @@ export default {
|
||||
homeContent: 'Home Page Content',
|
||||
homeContentPlaceholder: 'Enter custom content for the home page. Supports Markdown & HTML. If a URL is entered, it will be displayed as an iframe.',
|
||||
homeContentHint: 'Customize the home page content. Supports Markdown/HTML. If you enter a URL (starting with http:// or https://), it will be used as an iframe src to embed an external page. When set, the default status information will no longer be displayed.',
|
||||
homeContentIframeWarning: '⚠️ iframe mode note: Some websites have X-Frame-Options or CSP security policies that prevent embedding in iframes. If the page appears blank or shows an error, please verify the target website allows embedding, or consider using HTML mode to build your own content.'
|
||||
homeContentIframeWarning: '⚠️ iframe mode note: Some websites have X-Frame-Options or CSP security policies that prevent embedding in iframes. If the page appears blank or shows an error, please verify the target website allows embedding, or consider using HTML mode to build your own content.',
|
||||
hideCcsImportButton: 'Hide CCS Import Button',
|
||||
hideCcsImportButtonHint: 'When enabled, the "Import to CCS" button will be hidden on the API Keys page'
|
||||
},
|
||||
smtp: {
|
||||
title: 'SMTP Settings',
|
||||
|
||||
@@ -193,7 +193,8 @@ export default {
|
||||
expand: '展开',
|
||||
logout: '退出登录',
|
||||
github: 'GitHub',
|
||||
mySubscriptions: '我的订阅'
|
||||
mySubscriptions: '我的订阅',
|
||||
docs: '文档'
|
||||
},
|
||||
|
||||
// Auth
|
||||
@@ -568,7 +569,10 @@ export default {
|
||||
previous: '上一页',
|
||||
next: '下一页',
|
||||
perPage: '每页',
|
||||
goToPage: '跳转到第 {page} 页'
|
||||
goToPage: '跳转到第 {page} 页',
|
||||
jumpTo: '跳转页',
|
||||
jumpPlaceholder: '页码',
|
||||
jumpAction: '跳转'
|
||||
},
|
||||
|
||||
// Errors
|
||||
@@ -1021,7 +1025,7 @@ export default {
|
||||
title: '订阅管理',
|
||||
description: '管理用户订阅和配额限制',
|
||||
assignSubscription: '分配订阅',
|
||||
extendSubscription: '延长订阅',
|
||||
adjustSubscription: '调整订阅',
|
||||
revokeSubscription: '撤销订阅',
|
||||
allStatus: '全部状态',
|
||||
allGroups: '全部分组',
|
||||
@@ -1036,6 +1040,7 @@ export default {
|
||||
resetInHoursMinutes: '{hours} 小时 {minutes} 分钟后重置',
|
||||
resetInDaysHours: '{days} 天 {hours} 小时后重置',
|
||||
daysRemaining: '天剩余',
|
||||
remainingDays: '剩余天数',
|
||||
noExpiration: '无过期时间',
|
||||
status: {
|
||||
active: '生效中',
|
||||
@@ -1054,28 +1059,32 @@ export default {
|
||||
user: '用户',
|
||||
group: '订阅分组',
|
||||
validityDays: '有效期(天)',
|
||||
extendDays: '延长天数'
|
||||
adjustDays: '调整天数'
|
||||
},
|
||||
selectUser: '选择用户',
|
||||
selectGroup: '选择订阅分组',
|
||||
groupHint: '仅显示订阅计费类型的分组',
|
||||
validityHint: '订阅的有效天数',
|
||||
extendingFor: '为以下用户延长订阅',
|
||||
adjustingFor: '为以下用户调整订阅',
|
||||
currentExpiration: '当前到期时间',
|
||||
adjustDaysPlaceholder: '正数延长,负数缩短',
|
||||
adjustHint: '输入正数延长订阅,负数缩短订阅(缩短后剩余天数需大于0)',
|
||||
assign: '分配',
|
||||
assigning: '分配中...',
|
||||
extend: '延长',
|
||||
extending: '延长中...',
|
||||
adjust: '调整',
|
||||
adjusting: '调整中...',
|
||||
revoke: '撤销',
|
||||
noSubscriptionsYet: '暂无订阅',
|
||||
assignFirstSubscription: '分配一个订阅以开始使用。',
|
||||
subscriptionAssigned: '订阅分配成功',
|
||||
subscriptionExtended: '订阅延长成功',
|
||||
subscriptionAdjusted: '订阅调整成功',
|
||||
subscriptionRevoked: '订阅撤销成功',
|
||||
failedToLoad: '加载订阅列表失败',
|
||||
failedToAssign: '分配订阅失败',
|
||||
failedToExtend: '延长订阅失败',
|
||||
failedToAdjust: '调整订阅失败',
|
||||
failedToRevoke: '撤销订阅失败',
|
||||
adjustWouldExpire: '调整后剩余天数必须大于0',
|
||||
adjustOutOfRange: '调整天数必须在 -36500 到 36500 之间',
|
||||
pleaseSelectUser: '请选择用户',
|
||||
pleaseSelectGroup: '请选择分组',
|
||||
validityDaysRequired: '请输入有效的天数(至少1天)',
|
||||
@@ -1421,6 +1430,10 @@ export default {
|
||||
tlsFingerprint: {
|
||||
label: 'TLS 指纹模拟',
|
||||
hint: '模拟 Node.js/Claude Code 客户端的 TLS 指纹'
|
||||
},
|
||||
sessionIdMasking: {
|
||||
label: '会话 ID 伪装',
|
||||
hint: '启用后将在 15 分钟内固定 metadata.user_id 中的 session ID,使上游认为请求来自同一会话'
|
||||
}
|
||||
},
|
||||
expired: '已过期',
|
||||
@@ -2084,7 +2097,43 @@ export default {
|
||||
cacheCreationTokens: '缓存创建 Token',
|
||||
cacheReadTokens: '缓存读取 Token',
|
||||
failedToLoad: '加载使用记录失败',
|
||||
ipAddress: 'IP'
|
||||
billingType: '计费类型',
|
||||
allBillingTypes: '全部计费类型',
|
||||
billingTypeBalance: '钱包余额',
|
||||
billingTypeSubscription: '订阅套餐',
|
||||
ipAddress: 'IP',
|
||||
cleanup: {
|
||||
button: '清理',
|
||||
title: '清理使用记录',
|
||||
warning: '清理不可恢复,且会影响历史统计回看。',
|
||||
submit: '提交清理',
|
||||
submitting: '提交中...',
|
||||
confirmTitle: '确认清理',
|
||||
confirmMessage: '确定要提交清理任务吗?清理不可恢复。',
|
||||
confirmSubmit: '确认清理',
|
||||
cancel: '取消任务',
|
||||
cancelConfirmTitle: '确认取消',
|
||||
cancelConfirmMessage: '确定要取消该清理任务吗?',
|
||||
cancelConfirm: '确认取消',
|
||||
cancelSuccess: '清理任务已取消',
|
||||
cancelFailed: '取消清理任务失败',
|
||||
recentTasks: '最近清理任务',
|
||||
loadingTasks: '正在加载任务...',
|
||||
noTasks: '暂无清理任务',
|
||||
range: '时间范围',
|
||||
deletedRows: '删除数量',
|
||||
missingRange: '请选择时间范围',
|
||||
submitSuccess: '清理任务已创建',
|
||||
submitFailed: '创建清理任务失败',
|
||||
loadFailed: '加载清理任务失败',
|
||||
status: {
|
||||
pending: '待执行',
|
||||
running: '执行中',
|
||||
succeeded: '已完成',
|
||||
failed: '失败',
|
||||
canceled: '已取消'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Ops Monitoring
|
||||
@@ -2898,7 +2947,9 @@ export default {
|
||||
homeContent: '首页内容',
|
||||
homeContentPlaceholder: '在此输入首页内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性。',
|
||||
homeContentHint: '自定义首页内容,支持 Markdown/HTML。如果输入的是链接(以 http:// 或 https:// 开头),则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。设置后首页的状态信息将不再显示。',
|
||||
homeContentIframeWarning: '⚠️ iframe 模式提示:部分网站设置了 X-Frame-Options 或 CSP 安全策略,禁止被嵌入到 iframe 中。如果页面显示空白或报错,请确认目标网站允许被嵌入,或考虑使用 HTML 模式自行构建页面内容。'
|
||||
homeContentIframeWarning: '⚠️ iframe 模式提示:部分网站设置了 X-Frame-Options 或 CSP 安全策略,禁止被嵌入到 iframe 中。如果页面显示空白或报错,请确认目标网站允许被嵌入,或考虑使用 HTML 模式自行构建页面内容。',
|
||||
hideCcsImportButton: '隐藏 CCS 导入按钮',
|
||||
hideCcsImportButtonHint: '启用后将在 API Keys 页面隐藏"导入 CCS"按钮'
|
||||
},
|
||||
smtp: {
|
||||
title: 'SMTP 设置',
|
||||
|
||||
@@ -321,6 +321,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
contact_info: contactInfo.value,
|
||||
doc_url: docUrl.value,
|
||||
home_content: '',
|
||||
hide_ccs_import_button: false,
|
||||
linuxdo_oauth_enabled: false,
|
||||
version: siteVersion.value
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ export interface FetchOptions {
|
||||
export interface User {
|
||||
id: number
|
||||
username: string
|
||||
notes: string
|
||||
email: string
|
||||
role: 'admin' | 'user' // User role for authorization
|
||||
balance: number // User balance for API usage
|
||||
@@ -39,6 +38,11 @@ export interface User {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface AdminUser extends User {
|
||||
// 管理员备注(普通用户接口不返回)
|
||||
notes: string
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string
|
||||
password: string
|
||||
@@ -75,6 +79,7 @@ export interface PublicSettings {
|
||||
contact_info: string
|
||||
doc_url: string
|
||||
home_content: string
|
||||
hide_ccs_import_button: boolean
|
||||
linuxdo_oauth_enabled: boolean
|
||||
version: string
|
||||
}
|
||||
@@ -269,14 +274,19 @@ export interface Group {
|
||||
// Claude Code 客户端限制
|
||||
claude_code_only: boolean
|
||||
fallback_group_id: number | null
|
||||
// 模型路由配置(仅 anthropic 平台使用)
|
||||
model_routing: Record<string, number[]> | null
|
||||
model_routing_enabled: boolean
|
||||
account_count?: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface AdminGroup extends Group {
|
||||
// 模型路由配置(仅管理员可见,内部信息)
|
||||
model_routing: Record<string, number[]> | null
|
||||
model_routing_enabled: boolean
|
||||
|
||||
// 分组下账号数量(仅管理员可见)
|
||||
account_count?: number
|
||||
}
|
||||
|
||||
export interface ApiKey {
|
||||
id: number
|
||||
user_id: number
|
||||
@@ -483,6 +493,10 @@ export interface Account {
|
||||
// TLS指纹伪装(仅 Anthropic OAuth/SetupToken 账号有效)
|
||||
enable_tls_fingerprint?: boolean | null
|
||||
|
||||
// 会话ID伪装(仅 Anthropic OAuth/SetupToken 账号有效)
|
||||
// 启用后将在15分钟内固定 metadata.user_id 中的 session ID
|
||||
session_id_masking_enabled?: boolean | null
|
||||
|
||||
// 运行时状态(仅当启用对应限制时返回)
|
||||
current_window_cost?: number | null // 当前窗口费用
|
||||
active_sessions?: number | null // 当前活跃会话数
|
||||
@@ -632,7 +646,7 @@ export interface UsageLog {
|
||||
total_cost: number
|
||||
actual_cost: number
|
||||
rate_multiplier: number
|
||||
account_rate_multiplier?: number | null
|
||||
billing_type: number
|
||||
|
||||
stream: boolean
|
||||
duration_ms: number
|
||||
@@ -645,18 +659,57 @@ export interface UsageLog {
|
||||
// User-Agent
|
||||
user_agent: string | null
|
||||
|
||||
// IP 地址(仅管理员可见)
|
||||
ip_address: string | null
|
||||
|
||||
created_at: string
|
||||
|
||||
user?: User
|
||||
api_key?: ApiKey
|
||||
account?: Account
|
||||
group?: Group
|
||||
subscription?: UserSubscription
|
||||
}
|
||||
|
||||
export interface UsageLogAccountSummary {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface AdminUsageLog extends UsageLog {
|
||||
// 账号计费倍率(仅管理员可见)
|
||||
account_rate_multiplier?: number | null
|
||||
|
||||
// 用户请求 IP(仅管理员可见)
|
||||
ip_address?: string | null
|
||||
|
||||
// 最小账号信息(仅管理员接口返回)
|
||||
account?: UsageLogAccountSummary
|
||||
}
|
||||
|
||||
export interface UsageCleanupFilters {
|
||||
start_time: string
|
||||
end_time: string
|
||||
user_id?: number
|
||||
api_key_id?: number
|
||||
account_id?: number
|
||||
group_id?: number
|
||||
model?: string | null
|
||||
stream?: boolean | null
|
||||
billing_type?: number | null
|
||||
}
|
||||
|
||||
export interface UsageCleanupTask {
|
||||
id: number
|
||||
status: string
|
||||
filters: UsageCleanupFilters
|
||||
created_by: number
|
||||
deleted_rows: number
|
||||
error_message?: string | null
|
||||
canceled_by?: number | null
|
||||
canceled_at?: string | null
|
||||
started_at?: string | null
|
||||
finished_at?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface RedeemCode {
|
||||
id: number
|
||||
code: string
|
||||
@@ -880,6 +933,7 @@ export interface UsageQueryParams {
|
||||
group_id?: number
|
||||
model?: string
|
||||
stream?: boolean
|
||||
billing_type?: number | null
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
@sync="showSync = true"
|
||||
@create="showCreate = true"
|
||||
>
|
||||
<template #before>
|
||||
<template #after>
|
||||
<!-- Column Settings Dropdown -->
|
||||
<div class="relative" ref="columnDropdownRef">
|
||||
<button
|
||||
@@ -187,14 +187,14 @@ import AccountCapacityCell from '@/components/account/AccountCapacityCell.vue'
|
||||
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { formatDateTime, formatRelativeTime } from '@/utils/format'
|
||||
import type { Account, Proxy, Group } from '@/types'
|
||||
import type { Account, Proxy, AdminGroup } from '@/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const proxies = ref<Proxy[]>([])
|
||||
const groups = ref<Group[]>([])
|
||||
const groups = ref<AdminGroup[]>([])
|
||||
const selIds = ref<number[]>([])
|
||||
const showCreate = ref(false)
|
||||
const showEdit = ref(false)
|
||||
|
||||
@@ -1107,7 +1107,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useOnboardingStore } from '@/stores/onboarding'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Group, GroupPlatform, SubscriptionType } from '@/types'
|
||||
import type { AdminGroup, GroupPlatform, SubscriptionType } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
@@ -1202,7 +1202,7 @@ const fallbackGroupOptionsForEdit = computed(() => {
|
||||
return options
|
||||
})
|
||||
|
||||
const groups = ref<Group[]>([])
|
||||
const groups = ref<AdminGroup[]>([])
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const filters = reactive({
|
||||
@@ -1223,8 +1223,8 @@ const showCreateModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const submitting = ref(false)
|
||||
const editingGroup = ref<Group | null>(null)
|
||||
const deletingGroup = ref<Group | null>(null)
|
||||
const editingGroup = ref<AdminGroup | null>(null)
|
||||
const deletingGroup = ref<AdminGroup | null>(null)
|
||||
|
||||
const createForm = reactive({
|
||||
name: '',
|
||||
@@ -1529,7 +1529,7 @@ const handleCreateGroup = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = async (group: Group) => {
|
||||
const handleEdit = async (group: AdminGroup) => {
|
||||
editingGroup.value = group
|
||||
editForm.name = group.name
|
||||
editForm.description = group.description || ''
|
||||
@@ -1585,7 +1585,7 @@ const handleUpdateGroup = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (group: Group) => {
|
||||
const handleDelete = (group: AdminGroup) => {
|
||||
deletingGroup.value = group
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
@@ -720,6 +720,21 @@
|
||||
{{ t('admin.settings.site.homeContentIframeWarning') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Hide CCS Import Button -->
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
|
||||
>
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{
|
||||
t('admin.settings.site.hideCcsImportButton')
|
||||
}}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.site.hideCcsImportButtonHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="form.hide_ccs_import_button" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1007,6 +1022,7 @@ const form = reactive<SettingsForm>({
|
||||
contact_info: '',
|
||||
doc_url: '',
|
||||
home_content: '',
|
||||
hide_ccs_import_button: false,
|
||||
smtp_host: '',
|
||||
smtp_port: 587,
|
||||
smtp_username: '',
|
||||
@@ -1128,6 +1144,7 @@ async function saveSettings() {
|
||||
contact_info: form.contact_info,
|
||||
doc_url: form.doc_url,
|
||||
home_content: form.home_content,
|
||||
hide_ccs_import_button: form.hide_ccs_import_button,
|
||||
smtp_host: form.smtp_host,
|
||||
smtp_port: form.smtp_port,
|
||||
smtp_username: form.smtp_username,
|
||||
|
||||
@@ -85,6 +85,14 @@
|
||||
|
||||
<!-- Right: Actions -->
|
||||
<div class="ml-auto flex flex-wrap items-center justify-end gap-3">
|
||||
<button
|
||||
@click="loadSubscriptions"
|
||||
:disabled="loading"
|
||||
class="btn btn-secondary"
|
||||
:title="t('common.refresh')"
|
||||
>
|
||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
<!-- Column Settings Dropdown -->
|
||||
<div class="relative" ref="columnDropdownRef">
|
||||
<button
|
||||
@@ -136,14 +144,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="loadSubscriptions"
|
||||
:disabled="loading"
|
||||
class="btn btn-secondary"
|
||||
:title="t('common.refresh')"
|
||||
>
|
||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
<button @click="showAssignModal = true" class="btn btn-primary">
|
||||
<Icon name="plus" size="md" class="mr-2" />
|
||||
{{ t('admin.subscriptions.assignSubscription') }}
|
||||
@@ -359,10 +359,10 @@
|
||||
<button
|
||||
v-if="row.status === 'active'"
|
||||
@click="handleExtend(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"
|
||||
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"
|
||||
>
|
||||
<Icon name="clock" size="sm" />
|
||||
<span class="text-xs">{{ t('admin.subscriptions.extend') }}</span>
|
||||
<Icon name="calendar" size="sm" />
|
||||
<span class="text-xs">{{ t('admin.subscriptions.adjust') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="row.status === 'active'"
|
||||
@@ -512,10 +512,10 @@
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Extend Subscription Modal -->
|
||||
<!-- Adjust Subscription Modal -->
|
||||
<BaseDialog
|
||||
:show="showExtendModal"
|
||||
:title="t('admin.subscriptions.extendSubscription')"
|
||||
:title="t('admin.subscriptions.adjustSubscription')"
|
||||
width="narrow"
|
||||
@close="closeExtendModal"
|
||||
>
|
||||
@@ -527,7 +527,7 @@
|
||||
>
|
||||
<div class="rounded-lg bg-gray-50 p-4 dark:bg-dark-700">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.subscriptions.extendingFor') }}
|
||||
{{ t('admin.subscriptions.adjustingFor') }}
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{
|
||||
extendingSubscription.user?.email
|
||||
}}</span>
|
||||
@@ -542,10 +542,25 @@
|
||||
}}
|
||||
</span>
|
||||
</p>
|
||||
<p v-if="extendingSubscription.expires_at" class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.subscriptions.remainingDays') }}:
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{{ getDaysRemaining(extendingSubscription.expires_at) ?? 0 }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.subscriptions.form.extendDays') }}</label>
|
||||
<input v-model.number="extendForm.days" type="number" min="1" required class="input" />
|
||||
<label class="input-label">{{ t('admin.subscriptions.form.adjustDays') }}</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model.number="extendForm.days"
|
||||
type="number"
|
||||
required
|
||||
class="input text-center"
|
||||
:placeholder="t('admin.subscriptions.adjustDaysPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<p class="input-hint">{{ t('admin.subscriptions.adjustHint') }}</p>
|
||||
</div>
|
||||
</form>
|
||||
<template #footer>
|
||||
@@ -559,7 +574,7 @@
|
||||
:disabled="submitting"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
{{ submitting ? t('admin.subscriptions.extending') : t('admin.subscriptions.extend') }}
|
||||
{{ submitting ? t('admin.subscriptions.adjusting') : t('admin.subscriptions.adjust') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1000,17 +1015,27 @@ const closeExtendModal = () => {
|
||||
const handleExtendSubscription = async () => {
|
||||
if (!extendingSubscription.value) return
|
||||
|
||||
// 前端验证:调整后剩余天数必须 > 0
|
||||
if (extendingSubscription.value.expires_at) {
|
||||
const currentDaysRemaining = getDaysRemaining(extendingSubscription.value.expires_at) ?? 0
|
||||
const newDaysRemaining = currentDaysRemaining + extendForm.days
|
||||
if (newDaysRemaining <= 0) {
|
||||
appStore.showError(t('admin.subscriptions.adjustWouldExpire'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await adminAPI.subscriptions.extend(extendingSubscription.value.id, {
|
||||
days: extendForm.days
|
||||
})
|
||||
appStore.showSuccess(t('admin.subscriptions.subscriptionExtended'))
|
||||
appStore.showSuccess(t('admin.subscriptions.subscriptionAdjusted'))
|
||||
closeExtendModal()
|
||||
loadSubscriptions()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.subscriptions.failedToExtend'))
|
||||
console.error('Error extending subscription:', error)
|
||||
appStore.showError(error.response?.data?.detail || t('admin.subscriptions.failedToAdjust'))
|
||||
console.error('Error adjusting subscription:', error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
@@ -17,12 +17,19 @@
|
||||
<TokenUsageTrend :trend-data="trendData" :loading="chartsLoading" />
|
||||
</div>
|
||||
</div>
|
||||
<UsageFilters v-model="filters" v-model:startDate="startDate" v-model:endDate="endDate" :exporting="exporting" @change="applyFilters" @reset="resetFilters" @export="exportToExcel" />
|
||||
<UsageFilters v-model="filters" v-model:startDate="startDate" v-model:endDate="endDate" :exporting="exporting" @change="applyFilters" @reset="resetFilters" @cleanup="openCleanupDialog" @export="exportToExcel" />
|
||||
<UsageTable :data="usageLogs" :loading="loading" />
|
||||
<Pagination v-if="pagination.total > 0" :page="pagination.page" :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" @update:pageSize="handlePageSizeChange" />
|
||||
</div>
|
||||
</AppLayout>
|
||||
<UsageExportProgress :show="exportProgress.show" :progress="exportProgress.progress" :current="exportProgress.current" :total="exportProgress.total" :estimated-time="exportProgress.estimatedTime" @cancel="cancelExport" />
|
||||
<UsageCleanupDialog
|
||||
:show="cleanupDialogVisible"
|
||||
:filters="filters"
|
||||
:start-date="startDate"
|
||||
:end-date="endDate"
|
||||
@close="cleanupDialogVisible = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -33,15 +40,17 @@ import { useAppStore } from '@/stores/app'; import { adminAPI } from '@/api/admi
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'; import Pagination from '@/components/common/Pagination.vue'; import Select from '@/components/common/Select.vue'
|
||||
import UsageStatsCards from '@/components/admin/usage/UsageStatsCards.vue'; import UsageFilters from '@/components/admin/usage/UsageFilters.vue'
|
||||
import UsageTable from '@/components/admin/usage/UsageTable.vue'; import UsageExportProgress from '@/components/admin/usage/UsageExportProgress.vue'
|
||||
import UsageCleanupDialog from '@/components/admin/usage/UsageCleanupDialog.vue'
|
||||
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'; import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
|
||||
import type { UsageLog, TrendDataPoint, ModelStat } from '@/types'; import type { AdminUsageStatsResponse, AdminUsageQueryParams } from '@/api/admin/usage'
|
||||
import type { AdminUsageLog, TrendDataPoint, ModelStat } from '@/types'; import type { AdminUsageStatsResponse, AdminUsageQueryParams } from '@/api/admin/usage'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const usageStats = ref<AdminUsageStatsResponse | null>(null); const usageLogs = ref<UsageLog[]>([]); const loading = ref(false); const exporting = ref(false)
|
||||
const usageStats = ref<AdminUsageStatsResponse | null>(null); const usageLogs = ref<AdminUsageLog[]>([]); const loading = ref(false); const exporting = ref(false)
|
||||
const trendData = ref<TrendDataPoint[]>([]); const modelStats = ref<ModelStat[]>([]); const chartsLoading = ref(false); const granularity = ref<'day' | 'hour'>('day')
|
||||
let abortController: AbortController | null = null; let exportAbortController: AbortController | null = null
|
||||
const exportProgress = reactive({ show: false, progress: 0, current: 0, total: 0, estimatedTime: '' })
|
||||
const cleanupDialogVisible = ref(false)
|
||||
|
||||
const granularityOptions = computed(() => [{ value: 'day', label: t('admin.dashboard.day') }, { value: 'hour', label: t('admin.dashboard.hour') }])
|
||||
// Use local timezone to avoid UTC timezone issues
|
||||
@@ -53,7 +62,7 @@ const formatLD = (d: Date) => {
|
||||
}
|
||||
const now = new Date(); const weekAgo = new Date(); weekAgo.setDate(weekAgo.getDate() - 6)
|
||||
const startDate = ref(formatLD(weekAgo)); const endDate = ref(formatLD(now))
|
||||
const filters = ref<AdminUsageQueryParams>({ user_id: undefined, model: undefined, group_id: undefined, start_date: startDate.value, end_date: endDate.value })
|
||||
const filters = ref<AdminUsageQueryParams>({ user_id: undefined, model: undefined, group_id: undefined, billing_type: null, start_date: startDate.value, end_date: endDate.value })
|
||||
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
|
||||
|
||||
const loadLogs = async () => {
|
||||
@@ -67,22 +76,23 @@ const loadStats = async () => { try { const s = await adminAPI.usage.getStats(fi
|
||||
const loadChartData = async () => {
|
||||
chartsLoading.value = true
|
||||
try {
|
||||
const params = { start_date: filters.value.start_date || startDate.value, end_date: filters.value.end_date || endDate.value, granularity: granularity.value, user_id: filters.value.user_id, model: filters.value.model, api_key_id: filters.value.api_key_id, account_id: filters.value.account_id, group_id: filters.value.group_id, stream: filters.value.stream }
|
||||
const [trendRes, modelRes] = await Promise.all([adminAPI.dashboard.getUsageTrend(params), adminAPI.dashboard.getModelStats({ start_date: params.start_date, end_date: params.end_date, user_id: params.user_id, model: params.model, api_key_id: params.api_key_id, account_id: params.account_id, group_id: params.group_id, stream: params.stream })])
|
||||
const params = { start_date: filters.value.start_date || startDate.value, end_date: filters.value.end_date || endDate.value, granularity: granularity.value, user_id: filters.value.user_id, model: filters.value.model, api_key_id: filters.value.api_key_id, account_id: filters.value.account_id, group_id: filters.value.group_id, stream: filters.value.stream, billing_type: filters.value.billing_type }
|
||||
const [trendRes, modelRes] = await Promise.all([adminAPI.dashboard.getUsageTrend(params), adminAPI.dashboard.getModelStats({ start_date: params.start_date, end_date: params.end_date, user_id: params.user_id, model: params.model, api_key_id: params.api_key_id, account_id: params.account_id, group_id: params.group_id, stream: params.stream, billing_type: params.billing_type })])
|
||||
trendData.value = trendRes.trend || []; modelStats.value = modelRes.models || []
|
||||
} catch (error) { console.error('Failed to load chart data:', error) } finally { chartsLoading.value = false }
|
||||
}
|
||||
const applyFilters = () => { pagination.page = 1; loadLogs(); loadStats(); loadChartData() }
|
||||
const resetFilters = () => { startDate.value = formatLD(weekAgo); endDate.value = formatLD(now); filters.value = { start_date: startDate.value, end_date: endDate.value }; granularity.value = 'day'; applyFilters() }
|
||||
const resetFilters = () => { startDate.value = formatLD(weekAgo); endDate.value = formatLD(now); filters.value = { start_date: startDate.value, end_date: endDate.value, billing_type: null }; granularity.value = 'day'; applyFilters() }
|
||||
const handlePageChange = (p: number) => { pagination.page = p; loadLogs() }
|
||||
const handlePageSizeChange = (s: number) => { pagination.page_size = s; pagination.page = 1; loadLogs() }
|
||||
const cancelExport = () => exportAbortController?.abort()
|
||||
const openCleanupDialog = () => { cleanupDialogVisible.value = true }
|
||||
|
||||
const exportToExcel = async () => {
|
||||
if (exporting.value) return; exporting.value = true; exportProgress.show = true
|
||||
const c = new AbortController(); exportAbortController = c
|
||||
try {
|
||||
const all: UsageLog[] = []; let p = 1; let total = pagination.total
|
||||
const all: AdminUsageLog[] = []; let p = 1; let total = pagination.total
|
||||
while (true) {
|
||||
const res = await adminUsageAPI.list({ page: p, page_size: 100, ...filters.value }, { signal: c.signal })
|
||||
if (c.signal.aborted) break; if (p === 1) { total = res.total; exportProgress.total = total }
|
||||
|
||||
@@ -492,7 +492,7 @@ import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { User, UserAttributeDefinition } from '@/types'
|
||||
import type { AdminUser, UserAttributeDefinition } from '@/types'
|
||||
import type { BatchUserUsageStats } from '@/api/admin/dashboard'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
@@ -637,7 +637,7 @@ const columns = computed<Column[]>(() =>
|
||||
)
|
||||
)
|
||||
|
||||
const users = ref<User[]>([])
|
||||
const users = ref<AdminUser[]>([])
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
|
||||
@@ -736,16 +736,16 @@ const showEditModal = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const showApiKeysModal = ref(false)
|
||||
const showAttributesModal = ref(false)
|
||||
const editingUser = ref<User | null>(null)
|
||||
const deletingUser = ref<User | null>(null)
|
||||
const viewingUser = ref<User | null>(null)
|
||||
const editingUser = ref<AdminUser | null>(null)
|
||||
const deletingUser = ref<AdminUser | null>(null)
|
||||
const viewingUser = ref<AdminUser | null>(null)
|
||||
let abortController: AbortController | null = null
|
||||
|
||||
// Action Menu State
|
||||
const activeMenuId = ref<number | null>(null)
|
||||
const menuPosition = ref<{ top: number; left: number } | null>(null)
|
||||
|
||||
const openActionMenu = (user: User, e: MouseEvent) => {
|
||||
const openActionMenu = (user: AdminUser, e: MouseEvent) => {
|
||||
if (activeMenuId.value === user.id) {
|
||||
closeActionMenu()
|
||||
} else {
|
||||
@@ -821,11 +821,11 @@ const handleClickOutside = (event: MouseEvent) => {
|
||||
|
||||
// Allowed groups modal state
|
||||
const showAllowedGroupsModal = ref(false)
|
||||
const allowedGroupsUser = ref<User | null>(null)
|
||||
const allowedGroupsUser = ref<AdminUser | null>(null)
|
||||
|
||||
// Balance (Deposit/Withdraw) modal state
|
||||
const showBalanceModal = ref(false)
|
||||
const balanceUser = ref<User | null>(null)
|
||||
const balanceUser = ref<AdminUser | null>(null)
|
||||
const balanceOperation = ref<'add' | 'subtract'>('add')
|
||||
|
||||
// 计算剩余天数
|
||||
@@ -998,7 +998,7 @@ const applyFilter = () => {
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
const handleEdit = (user: User) => {
|
||||
const handleEdit = (user: AdminUser) => {
|
||||
editingUser.value = user
|
||||
showEditModal.value = true
|
||||
}
|
||||
@@ -1008,7 +1008,7 @@ const closeEditModal = () => {
|
||||
editingUser.value = null
|
||||
}
|
||||
|
||||
const handleToggleStatus = async (user: User) => {
|
||||
const handleToggleStatus = async (user: AdminUser) => {
|
||||
const newStatus = user.status === 'active' ? 'disabled' : 'active'
|
||||
try {
|
||||
await adminAPI.users.toggleStatus(user.id, newStatus)
|
||||
@@ -1022,7 +1022,7 @@ const handleToggleStatus = async (user: User) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewApiKeys = (user: User) => {
|
||||
const handleViewApiKeys = (user: AdminUser) => {
|
||||
viewingUser.value = user
|
||||
showApiKeysModal.value = true
|
||||
}
|
||||
@@ -1032,7 +1032,7 @@ const closeApiKeysModal = () => {
|
||||
viewingUser.value = null
|
||||
}
|
||||
|
||||
const handleAllowedGroups = (user: User) => {
|
||||
const handleAllowedGroups = (user: AdminUser) => {
|
||||
allowedGroupsUser.value = user
|
||||
showAllowedGroupsModal.value = true
|
||||
}
|
||||
@@ -1042,7 +1042,7 @@ const closeAllowedGroupsModal = () => {
|
||||
allowedGroupsUser.value = null
|
||||
}
|
||||
|
||||
const handleDelete = (user: User) => {
|
||||
const handleDelete = (user: AdminUser) => {
|
||||
deletingUser.value = user
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
@@ -1061,13 +1061,13 @@ const confirmDelete = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeposit = (user: User) => {
|
||||
const handleDeposit = (user: AdminUser) => {
|
||||
balanceUser.value = user
|
||||
balanceOperation.value = 'add'
|
||||
showBalanceModal.value = true
|
||||
}
|
||||
|
||||
const handleWithdraw = (user: User) => {
|
||||
const handleWithdraw = (user: AdminUser) => {
|
||||
balanceUser.value = user
|
||||
balanceOperation.value = 'subtract'
|
||||
showBalanceModal.value = true
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
</button>
|
||||
<!-- Import to CC Switch Button -->
|
||||
<button
|
||||
v-if="!publicSettings?.hide_ccs_import_button"
|
||||
@click="importToCcswitch(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"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user